const requestClient = require('./request') const util = require('./util') const roleNames = { worker: '冲压工人', admin: '管理员', manager: '经理', } const defaultRolePriority = ['worker', 'admin', 'manager'] const pickDefaultRole = people => { const availableRoles = [] const personList = people || [] personList.forEach(person => { if (person.role) { availableRoles.push(person.role) } const roles = person.roles || [] roles.forEach(role => { availableRoles.push(role) }) }) const uniqueRoles = Array.from(new Set(availableRoles)) return defaultRolePriority.find(role => uniqueRoles.includes(role)) || uniqueRoles[0] || '' } const normalizeDeviceNo = value => { return String(value || '').trim() } const moldDisplayName = (name, processName, stampingMethod = '') => { const moldName = String(name || '').trim() const process = String(processName || '').trim() const stamping = String(stampingMethod || '').trim() if (!moldName) { return '' } if (stamping) { return `${moldName} / ${process || '-'} / ${stamping}` } return process ? `${moldName} / ${process}` : moldName } const normalizeProcessValue = value => { const text = String(value || '').trim() if (text === '杂活') { return text } const legacyMatch = text.match(/^0*(\d+)序$/) if (legacyMatch) { return String(Number(legacyMatch[1])) } const numberMatch = text.match(/^0*(\d+)$/) if (numberMatch) { return String(Number(numberMatch[1])) } return text } const processDisplayName = value => { const process = normalizeProcessValue(value) if (!process) { return '' } return process === '杂活' ? process : `${process}序` } const isCleaningStampingMethod = value => String(value || '').trim() === '清洗' const isCleaningRow = row => ( isCleaningStampingMethod(row.stamping_method || row.stampingMethod) ) const CONTINUOUS_DIE_STAMPING_METHOD = '连续模' const isContinuousDieStampingMethod = value => String(value || '').trim() === CONTINUOUS_DIE_STAMPING_METHOD const isContinuousDieRow = row => ( !!row && ( !!row.is_continuous_die || !!row.isContinuousDie || isContinuousDieStampingMethod(row.stamping_method || row.stampingMethod) ) ) const MISC_WORK_PRODUCT_NAME = '处理杂活' const MISC_WORK_PROCESS_NAME = '杂活' const MISC_WORK_STAMPING_METHOD = '处理杂活' const isMiscRow = row => ( !!row && ( row.is_misc || row.isMisc || String(row.stamping_method || row.stampingMethod || '').trim() === MISC_WORK_STAMPING_METHOD || ( String(row.product_name || row.productName || row.name || row.moldName || '').trim() === MISC_WORK_PRODUCT_NAME && String(row.process_name || row.process || row.processName || '').trim() === MISC_WORK_PROCESS_NAME ) ) ) const isMultiPersonRow = row => ( !!row && ( !!row.is_multi_person || !!row.isMultiPerson || Number(row.operator_count || row.operatorCount || 1) > 1 ) ) const correctionValue = value => ( value === null || value === undefined || value === '' ? '-' : value ) const correctionDisplayValue = value => { const normalized = correctionValue(value) if (typeof normalized === 'string' && normalized.includes('T')) { return util.formatTime(normalized) } return normalized } const correctionPair = (corrections, key, label, currentValue) => { if (!corrections || corrections[key] === undefined) { return null } return { key, label, oldValue: correctionValue(corrections[key]), newValue: correctionValue(currentValue), } } const round1 = value => Math.round(Number(value || 0) * 10) / 10 const round2 = value => Math.round(Number(value || 0) * 100) / 100 const calculateStandardWorkload = (allocatedMinutes, standardBeat) => { const beat = Number(standardBeat || 0) if (beat <= 0) { return 0 } return round1(Number(allocatedMinutes || 0) * 60 / beat) } const classifyBeat = (actualBeat, standardBeat) => { const actual = Number(actualBeat || 0) const standard = Number(standardBeat || 0) if (standard <= 0 || actual <= 0) { return { className: '', reason: '', isAbnormal: false } } if (actual > standard) { return { className: 'metric-danger', reason: '慢', isAbnormal: true } } if (actual < standard * 0.7) { return { className: 'metric-danger', reason: '快', isAbnormal: true } } if (actual < standard) { return { className: 'metric-success', reason: '', isAbnormal: false } } return { className: '', reason: '', isAbnormal: false } } const classifyWorkload = (goodQty, standardWorkload) => { const good = Number(goodQty || 0) const standard = Number(standardWorkload || 0) if (standard <= 0) { return { className: '', reason: '', isAbnormal: false } } if (good < standard) { return { className: 'metric-danger', reason: '小', isAbnormal: true } } if (good > standard * 1.3) { return { className: 'metric-danger', reason: '大', isAbnormal: true } } if (good > standard) { return { className: 'metric-success', reason: '', isAbnormal: false } } return { className: '', reason: '', isAbnormal: false } } const toCamelAttendancePoint = row => ({ name: row.name || '', latitude: row.latitude === null || row.latitude === undefined ? '' : row.latitude, longitude: row.longitude === null || row.longitude === undefined ? '' : row.longitude, radiusMeters: row.radius_meters === null || row.radius_meters === undefined ? 500 : row.radius_meters, dayStart: row.day_start || '08:00', dayEnd: row.day_end || '17:20', lunchStart: row.lunch_start || '11:40', lunchEnd: row.lunch_end || '12:40', dinnerStart: row.dinner_start || '17:20', dinnerEnd: row.dinner_end || '18:00', overtimeStart: row.overtime_start || '18:00', overtimeEnd: row.overtime_end || '20:00', nightStart: row.night_start || '20:00', nightEnd: row.night_end || '06:00', remark: row.remark || '', isActive: row.is_active !== false, createdAt: row.created_at || '', updatedAt: row.updated_at || '', updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', }) const toSnakeAttendancePoint = row => ({ name: row.name, latitude: row.latitude === '' || row.latitude === undefined ? null : Number(row.latitude), longitude: row.longitude === '' || row.longitude === undefined ? null : Number(row.longitude), radius_meters: row.radiusMeters === '' || row.radiusMeters === undefined ? 500 : Number(row.radiusMeters), day_start: row.dayStart || '08:00', day_end: row.dayEnd || '17:20', lunch_start: row.lunchStart || '11:40', lunch_end: row.lunchEnd || '12:40', dinner_start: row.dinnerStart || '17:20', dinner_end: row.dinnerEnd || '18:00', overtime_start: row.overtimeStart || '18:00', overtime_end: row.overtimeEnd || '20:00', night_start: row.nightStart || '20:00', night_end: row.nightEnd || '06:00', remark: row.remark || null, is_active: row.isActive !== false, original_name: row.originalName || null, }) const toCamelPerson = row => { const roles = row.roles && row.roles.length ? row.roles : (row.role ? [row.role] : []) const roleNamesList = row.role_names && row.role_names.length ? row.role_names : roles.map(role => roleNames[role] || role) const temporaryExpiresAtText = row.temporary_expires_at ? util.formatTime(row.temporary_expires_at) : '' const attendancePointNames = row.attendance_point_names || [] return { phone: row.phone, name: row.name, role: row.role || roles[0] || '', roleName: row.role_name || roleNamesList.join('、'), roles, roleNames: roleNamesList, attendancePointNames, attendancePoints: (row.attendance_points || []).map(toCamelAttendancePoint), attendancePointsText: attendancePointNames.join('、'), rolesDisplay: roles.map((role, index) => ({ role, roleName: roleNamesList[index] || roleNames[role] || role, })), isTemporary: !!row.is_temporary, temporaryExpiresAt: row.temporary_expires_at || '', temporaryExpiresAtText, temporaryExpired: !!row.temporary_expired, temporaryStatusText: row.is_temporary ? (row.temporary_expired ? '临时工已过期' : `临时工有效至 ${temporaryExpiresAtText || '-'}`) : '', } } const toSnakePerson = row => ({ phone: row.phone, name: row.name, role: row.role, attendance_point_names: row.attendancePointNames || [], original_phone: row.originalPhone || null, }) const toCamelProduct = row => ({ attendancePointName: row.attendance_point_name || '', projectNo: row.project_no, productName: row.product_name, profileNo: row.profile_no || '', materialCode: row.material_code || '', materialName: row.material_name || '', supplier: row.supplier || '', productNetWeightKg: row.product_net_weight_kg === null || row.product_net_weight_kg === undefined ? '' : row.product_net_weight_kg, productGrossWeightKg: row.product_gross_weight_kg === null || row.product_gross_weight_kg === undefined ? '' : row.product_gross_weight_kg, allowedScrapRate: row.scrap_loss_rate === null || row.scrap_loss_rate === undefined ? '' : row.scrap_loss_rate, wastePriceYuanPerKg: row.waste_price_yuan_per_kg === null || row.waste_price_yuan_per_kg === undefined ? '' : row.waste_price_yuan_per_kg, deviceNo: row.device_no, process: normalizeProcessValue(row.process_name || ''), rawProcess: row.process_name || '', processDisplay: processDisplayName(row.process_name || ''), stampingMethod: row.stamping_method || '', isCleaning: isCleaningRow(row), isContinuousDie: isContinuousDieRow(row), isMisc: isMiscRow(row), isMultiPerson: isMultiPersonRow(row), operatorCount: row.operator_count, processUnitPriceYuan: row.process_unit_price_yuan || 0, standardBeat: row.standard_beat, standardWorkload: row.standard_workload, displayName: row.display_name || `${row.project_no} / ${moldDisplayName(row.product_name, row.process_name, row.stamping_method)}`, uniqueKey: `${row.attendance_point_name || ''}||${row.project_no || ''}||${row.product_name || ''}||${row.process_name || ''}`, }) const toCamelNotice = row => ({ id: row.id, title: row.title || '', content: row.content || '', attendancePointNames: row.attendance_point_names || [], attendancePointsText: (row.attendance_point_names || []).join('、'), sortOrder: row.sort_order || 0, isActive: !!row.is_active, createdBy: row.created_by || '', creatorName: row.creator_name || '', createdAt: row.created_at || '', updatedAt: row.updated_at || '', updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', }) const toCamelEquipment = row => ({ attendancePointName: row.attendance_point_name || '', deviceNo: row.device_no || '', deviceType: row.device_type || '冲压设备', remark: row.remark || '', createdAt: row.created_at || '', updatedAt: row.updated_at || '', updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', displayName: row.display_name || (row.remark ? `${row.device_no} / ${row.remark}` : row.device_no), uniqueKey: `${row.attendance_point_name || ''}||${row.device_no || ''}`, }) const toCamelMoldOption = row => ({ attendancePointName: row.attendance_point_name || '', name: row.name || row.device_no || '', moldName: row.name || row.device_no || '', processName: row.process_name || '', stampingMethod: row.stamping_method || '', operatorCount: row.operator_count || 1, isCleaning: !!row.is_cleaning || isCleaningRow(row), isMisc: !!row.is_misc || isMiscRow(row), isContinuousDie: !!row.is_continuous_die || isContinuousDieRow(row), isMultiPerson: isMultiPersonRow(row), displayName: row.display_name || moldDisplayName(row.name || row.device_no, row.process_name, row.stamping_method), uniqueKey: `${row.attendance_point_name || ''}||${row.name || row.device_no || ''}||${row.process_name || ''}`, }) const optionalNumber = value => ( value === '' || value === undefined || value === null ? null : Number(value) ) const toSnakeProduct = row => ({ attendance_point_name: row.attendancePointName, project_no: row.projectNo, product_name: row.productName, profile_no: row.profileNo || null, material_code: row.materialCode || null, material_name: row.materialName || null, supplier: row.supplier || null, product_net_weight_kg: optionalNumber(row.productNetWeightKg), product_gross_weight_kg: optionalNumber(row.productGrossWeightKg), scrap_loss_rate: optionalNumber(row.allowedScrapRate), waste_price_yuan_per_kg: optionalNumber(row.wastePriceYuanPerKg), device_no: '', process_name: normalizeProcessValue(row.process || ''), stamping_method: row.stampingMethod || null, operator_count: Number(row.operatorCount || 1), process_unit_price_yuan: Number(row.processUnitPriceYuan || 0), standard_beat: Number(row.standardBeat || 0), original_project_no: row.originalProjectNo || null, original_attendance_point_name: row.originalAttendancePointName || null, original_product_name: row.originalProductName || null, original_device_no: row.originalDeviceNo !== undefined ? row.originalDeviceNo : '', original_process_name: row.originalProcess !== undefined ? row.originalProcess : null, }) const toSnakeNotice = row => ({ title: row.title, content: row.content, attendance_point_names: row.attendancePointNames || [], sort_order: Number(row.sortOrder || 0), is_active: !!row.isActive, }) const toSnakeEquipment = row => ({ attendance_point_name: row.attendancePointName, device_no: normalizeDeviceNo(row.deviceNo), device_type: row.deviceType || '冲压设备', remark: row.remark || null, original_attendance_point_name: row.originalAttendancePointName || null, original_device_no: row.originalDeviceNo || null, }) const toCamelPage = (page, mapper) => ({ rows: (page.rows || []).map(mapper), page: page.page, pageSize: page.page_size, total: page.total, totalPages: page.total_pages, }) const toCamelUsageStatsRow = row => ({ id: row.id === undefined || row.id === null ? '' : String(row.id), category: row.category || '', attendancePointName: row.attendance_point_name || '', name: row.name || '', objectType: row.object_type || '', metricKind: row.metric_kind || '', value: Number(row.value || 0), reportCount: Number(row.report_count || 0), productName: row.product_name || '', processName: row.process_name || '', stampingMethod: row.stamping_method || '', tags: Array.isArray(row.tags) ? row.tags : [], }) const toCamelUsageStatsDailyRow = row => ({ reportDate: row.report_date || '', value: Number(row.value || 0), reportCount: Number(row.report_count || 0), }) const toCamelUsageStatsReportRow = row => ({ reportId: Number(row.report_id || 0), reportDate: row.report_date || '', employeePhone: row.employee_phone || '', employeeName: row.employee_name || '', displayName: row.display_name || '', value: Number(row.value || 0), reportCount: Number(row.report_count || 0), }) const toCamelUsageStatsDetail = data => ({ ...toCamelUsageStatsRow(data || {}), dailyRows: ((data && data.daily_rows) || []).map(toCamelUsageStatsDailyRow), reportRows: ((data && data.report_rows) || []).map(toCamelUsageStatsReportRow), }) const usageStatsParams = filters => ({ category: filters.category, id: filters.id, metric_kind: filters.metricKind, sort_by: filters.sortBy, start_date: filters.startDate, end_date: filters.endDate, attendance_point_name: filters.attendancePointName, keyword: filters.keyword, name: filters.name, product_name: filters.productName, process_name: filters.processName, stamping_method: filters.stampingMethod, }) const formatDurationMinutes = minutes => { const totalMinutes = Math.max(0, Math.round(Number(minutes || 0))) if (totalMinutes < 60) { return `${totalMinutes}分钟` } const days = Math.floor(totalMinutes / 1440) const hours = Math.floor((totalMinutes % 1440) / 60) const restMinutes = totalMinutes % 60 const parts = [] if (days) { parts.push(`${days}天`) } if (hours) { parts.push(`${hours}小时`) } if (restMinutes) { parts.push(`${restMinutes}分钟`) } return parts.join('') } const trimDurationNumber = value => { const rounded = Math.round(Number(value || 0) * 100) / 100 return Number.isInteger(rounded) ? String(rounded) : String(rounded) } const formatDurationAllUnits = minutes => { const safeMinutes = Math.max(0, Number(minutes || 0)) return `${trimDurationNumber(safeMinutes)}分钟(${trimDurationNumber(safeMinutes / 60)}小时 / ${trimDurationNumber(safeMinutes / 1440)}天)` } const toCamelMonitorLock = row => ({ id: row.id, attendancePointName: row.attendance_point_name || '', moldName: row.mold_name || '', processName: row.process_name || '', stampingMethod: row.stamping_method || '', moldDisplayName: row.mold_display_name || moldDisplayName(row.mold_name, row.process_name, row.stamping_method), occupiedPhone: row.occupied_phone || '', occupiedName: row.occupied_name || '', occupiedStartAt: row.occupied_start_at || '', occupiedStartAtText: row.occupied_start_at ? util.formatTime(row.occupied_start_at) : '', occupiedMinutes: row.occupied_minutes || 0, occupiedDurationText: formatDurationMinutes(row.occupied_minutes), }) const toCamelMoldLockFeedback = row => ({ id: row.id, attendancePointName: row.attendance_point_name || '', moldName: row.mold_name || '', processName: row.process_name || '', stampingMethod: row.stamping_method || '', moldDisplayName: row.mold_display_name || moldDisplayName(row.mold_name, row.process_name, row.stamping_method), lockId: row.lock_id, reporterPhone: row.reporter_phone || '', reporterName: row.reporter_name || '', occupiedPhone: row.occupied_phone || '', occupiedName: row.occupied_name || '', feedbackAt: row.feedback_at || '', feedbackAtText: row.feedback_at ? util.formatTime(row.feedback_at) : '', readAt: row.read_at || '', readAtText: row.read_at ? util.formatTime(row.read_at) : '', handledAt: row.handled_at || '', handledAtText: row.handled_at ? util.formatTime(row.handled_at) : '', status: row.status || '', statusName: row.status_name || '', isHandled: row.status === 'handled', canRelease: row.can_release !== false, }) const toCamelSession = row => ({ id: row.id, attendancePointName: row.attendance_point_name || '', employeePhone: row.employee_phone, startAt: row.start_at, endAt: row.end_at || '', status: row.status, devices: row.devices || [], }) const toCamelMetrics = row => ({ effectiveMinutes: row.effective_minutes, shiftDistributionText: row.shift_distribution_text || '', shiftDayMinutes: row.shift_day_minutes || 0, shiftOvertimeMinutes: row.shift_overtime_minutes || 0, shiftNightMinutes: row.shift_night_minutes || 0, shiftOtherMinutes: row.shift_other_minutes || 0, shiftMealBreakMinutes: row.shift_meal_break_minutes || 0, totalGood: row.total_good_qty, totalOutput: row.total_output_qty, actualBeat: row.actual_beat, standardBeat: row.standard_beat, expectedWorkload: row.expected_workload, paceRate: row.pace_rate, workloadRate: row.workload_rate, }) const formatWorkTime = minutes => { const safeMinutes = Number(minutes || 0) const seconds = Math.round(safeMinutes * 60) if (seconds < 60) { return `${seconds}秒` } return `${Math.round(safeMinutes * 100) / 100}分钟` } const toCamelAllocation = row => ({ allocationDate: row.allocation_date || '', dayMinutes: Number(row.day_minutes || 0), overtimeMinutes: Number(row.overtime_minutes || 0), nightMinutes: Number(row.night_minutes || 0), effectiveMinutes: Number(row.effective_minutes || 0), goodQty: Number(row.good_qty || 0), defectQty: Number(row.defect_qty || 0), scrapQty: Number(row.scrap_qty || 0), changeoverCount: Number(row.changeover_count || 0), referenceWage: Number(row.reference_wage || 0), }) const toCamelReportItem = row => { const defectQty = Number(row.defect_qty || 0) const scrapQty = Number(row.scrap_qty || 0) const goodQty = Number(row.good_qty || 0) const totalDefectQty = defectQty + scrapQty const outputQty = goodQty + totalDefectQty const allocatedMinutes = Number(row.allocated_minutes || 0) const actualBeat = outputQty > 0 ? round2(allocatedMinutes * 60 / outputQty) : 0 const standardBeat = Number(row.standard_beat || 0) const calculatedWorkload = calculateStandardWorkload(allocatedMinutes, standardBeat) const standardWorkload = calculatedWorkload || Number(row.standard_workload || 0) const paceRate = standardBeat > 0 ? round1((actualBeat - standardBeat) / standardBeat * 100) : 0 const workloadRate = standardWorkload > 0 ? round1((goodQty - standardWorkload) / standardWorkload * 100) : 0 const isCleaning = isCleaningRow(row) const isContinuousDie = isContinuousDieRow(row) const isMisc = isMiscRow(row) const noOutputMetric = isCleaning || isMisc const beatComparison = noOutputMetric ? classifyBeat(0, 0) : classifyBeat(actualBeat, standardBeat) const workloadComparison = noOutputMetric ? classifyWorkload(0, 0) : classifyWorkload(goodQty, standardWorkload) const corrections = row.corrections || {} const correctionPairs = [ correctionPair(corrections, 'device_no', '设备号', row.device_no), correctionPair(corrections, 'project_no', '项目号', row.project_no), correctionPair(corrections, 'product_name', '产品名称', row.product_name), correctionPair(corrections, 'process_name', '工序', row.process_name || ''), correctionPair(corrections, 'stamping_method', '冲压方式', row.stamping_method || ''), correctionPair(corrections, 'raw_material_batch_no', '材料库存批次号', row.raw_material_batch_no || ''), correctionPair(corrections, 'process_unit_price_yuan', '工序单价', row.process_unit_price_yuan || 0), correctionPair(corrections, 'changeover_count', '换料次数', row.changeover_count || 0), correctionPair(corrections, 'good_qty', isCleaning ? '清洗数量' : '成品数量', goodQty), correctionPair(corrections, 'defect_qty', '不良数量', Math.round(totalDefectQty * 100) / 100), correctionPair(corrections, 'scrap_qty', '报废数量', scrapQty), correctionPair(corrections, 'remark', '杂活事项', row.remark || ''), ].filter(Boolean) return { id: row.id, attendancePointName: row.attendance_point_name || '', startAt: row.started_at || '', startAtText: row.started_at ? util.formatTime(row.started_at) : '', deviceNo: row.device_no, projectNo: row.project_no, productName: row.product_name, displayName: moldDisplayName(row.product_name, row.process_name, row.stamping_method), materialCode: row.material_code || '', materialName: row.material_name || '', rawMaterialBatchNo: row.raw_material_batch_no || '', process: row.process_name || '', stampingMethod: row.stamping_method || '', operatorCount: row.operator_count || 1, processUnitPriceYuan: row.process_unit_price_yuan || 0, isCleaning, isContinuousDie, changeoverCount: row.changeover_count === null || row.changeover_count === undefined ? 0 : Number(row.changeover_count || 0), isMisc, isMultiPerson: isMultiPersonRow(row), isMultiPersonAssistant: !!row.is_multi_person_assistant, standardBeat, standardWorkload, goodQty, defectQty: Math.round(totalDefectQty * 100) / 100, scrapQty, allocatedMinutes, workTimeText: isCleaning ? '不计工时' : formatWorkTime(allocatedMinutes), actualBeat, paceRate, workloadRate, paceText: noOutputMetric ? '不计节拍' : (paceRate > 0 ? `慢${Math.abs(paceRate)}%` : `快${Math.abs(paceRate)}%`), workloadText: noOutputMetric ? '不计标准工作量' : (workloadRate >= 0 ? `多${Math.abs(workloadRate)}%` : `少${Math.abs(workloadRate)}%`), beatCompareClass: beatComparison.className, beatReason: beatComparison.reason, workloadCompareClass: workloadComparison.className, workloadReason: workloadComparison.reason, remark: row.remark || '', corrections, correctionPairs, hasCorrections: correctionPairs.length > 0, allocations: (row.allocations || []).map(toCamelAllocation), } } const toCamelDeviceSegment = row => { const corrections = row.corrections || {} const correctionPairs = [ correctionPair(corrections, 'scanned_at', '换模具时间', row.scanned_at ? util.formatTime(row.scanned_at) : ''), ].filter(Boolean).map(item => ({ ...item, oldValue: item.oldValue === '-' ? '-' : util.formatTime(item.oldValue), })) return { id: row.id, attendancePointName: row.attendance_point_name || '', deviceNo: row.device_no || '', moldName: row.mold_name || row.device_no || '', process: row.process_name || '', stampingMethod: row.stamping_method || '', displayName: row.display_name || moldDisplayName(row.mold_name || row.device_no, row.process_name, row.stamping_method), scannedAt: row.scanned_at || '', scannedAtText: row.scanned_at ? util.formatTime(row.scanned_at) : '', releasedAt: row.released_at || '', releasedAtText: row.released_at ? util.formatTime(row.released_at) : '', sortOrder: Number(row.sort_order || 0), corrections, correctionPairs, hasCorrections: correctionPairs.length > 0, } } const toCamelReport = row => { const paceRate = Number(row.metrics && row.metrics.pace_rate || 0) const workloadRate = Number(row.metrics && row.metrics.workload_rate || 0) const items = (row.items || []).map(toCamelReportItem) const deviceSegments = (row.device_segments || []).map(toCamelDeviceSegment) const isCleaning = items.length > 0 && items.every(item => item.isCleaning) const hasMisc = items.some(item => item.isMisc) const hasContinuousDie = items.some(item => item.isContinuousDie) const hasMultiPerson = items.some(item => item.isMultiPerson) const totalDefect = Math.round(items.reduce((sum, item) => sum + Number(item.defectQty || 0), 0) * 100) / 100 const corrections = row.corrections || {} const reportCorrectionPairs = [ correctionPair(corrections, 'start_at', '上班时间', row.start_at ? util.formatTime(row.start_at) : ''), correctionPair(corrections, 'end_at', '下班时间', row.end_at ? util.formatTime(row.end_at) : ''), correctionPair(corrections, 'review_remark', '备注', row.review_remark || ''), ].filter(Boolean).map(item => ({ ...item, oldValue: (item.key === 'start_at' || item.key === 'end_at') && item.oldValue !== '-' ? util.formatTime(item.oldValue) : item.oldValue, })).concat(deviceSegments.flatMap(segment => ( (segment.correctionPairs || []).map(pair => ({ ...pair, label: `${segment.displayName} ${pair.label}`, })) ))) return { id: row.id, attendancePointName: row.attendance_point_name || '', sessionId: row.session_id, employeePhone: row.employee_phone, employeeName: row.employee_name, reportDate: row.report_date, startAt: row.start_at, endAt: row.end_at, startAtText: util.formatTime(row.start_at), endAtText: util.formatTime(row.end_at), durationMinutes: row.duration_minutes, breakMinutes: row.break_minutes, status: row.status, statusName: row.status_name, reviewerPhone: row.reviewer_phone || '', reviewerName: row.reviewer_name || '', reviewedAt: row.reviewed_at || '', reviewedAtText: row.reviewed_at ? util.formatTime(row.reviewed_at) : '', rejectReason: row.reject_reason || '', reviewRemark: row.review_remark || '', submittedAt: row.submitted_at, submittedAtText: util.formatTime(row.submitted_at), isSystemAutoSubmitted: !!row.is_system_auto_submitted, autoSubmitReason: row.auto_submit_reason || '超时-系统自动提交', isMultiPersonAssistant: !!row.is_multi_person_assistant, multiPersonSourceReportId: row.multi_person_source_report_id || null, isVoided: !!row.is_voided, voidedAt: row.voided_at || '', voidedAtText: row.voided_at ? util.formatTime(row.voided_at) : '', voidedBy: row.voided_by || '', voidedByName: row.voided_by_name || '', unvoidDeadlineAt: row.unvoid_deadline_at || '', unvoidDeadlineAtText: row.unvoid_deadline_at ? util.formatTime(row.unvoid_deadline_at) : '', canVoid: !!row.can_void, canUnvoid: !!row.can_unvoid, canEdit: !!row.can_edit, isModified: !!row.is_modified, allocationSummaryText: row.allocation_summary_text || '', deviceSegments, items, isCleaning, hasMisc, hasContinuousDie, hasMultiPerson, metrics: { ...toCamelMetrics(row.metrics || {}), totalDefect, }, resultText: row.result_text, paceText: paceRate > 0 ? `慢${Math.abs(paceRate)}%` : `快${Math.abs(paceRate)}%`, workloadText: workloadRate >= 0 ? `多${Math.abs(workloadRate)}%` : `少${Math.abs(workloadRate)}%`, corrections, correctionPairs: reportCorrectionPairs, hasCorrections: reportCorrectionPairs.length > 0 || items.some(item => item.hasCorrections) || deviceSegments.some(item => item.hasCorrections), } } const emptyDraftItem = startAt => ({ attendancePointName: '', productIndex: -1, deviceIndex: -1, deviceNo: '', startAt: startAt || '', startAtText: startAt ? util.formatTime(startAt) : '', projectNo: '', productName: '', materialCode: '', materialName: '', rawMaterialBatchNo: '', rawMaterialBatchOptions: [], rawMaterialBatchIndex: -1, rawMaterialBatchError: false, process: '', stampingMethod: '', operatorCount: 1, processUnitPriceYuan: 0, isContinuousDie: false, isMultiPerson: false, isMisc: false, standardBeat: 0, standardWorkload: 0, changeoverCount: '', changeoverCountError: false, goodQty: '', defectQty: '', deviceError: false, }) const batchIndexOf = (options = [], value = '') => { if (!value) { return -1 } return (options || []).findIndex(option => String(option) === String(value)) } const optionToDraftItem = (option, productIndex = 0, startAt = '', equipmentOptions = [], deviceNo = '', rawMaterialBatchOptions = [], rawMaterialBatchNo = '') => { const selectedDeviceNo = deviceNo || '' const deviceIndex = selectedDeviceNo ? Math.max(0, equipmentOptions.findIndex(item => item.deviceNo === selectedDeviceNo)) : -1 const batchOptions = rawMaterialBatchOptions || [] return { productIndex, attendancePointName: option ? option.attendancePointName : '', deviceIndex, deviceNo: selectedDeviceNo, startAt: startAt || '', startAtText: startAt ? util.formatTime(startAt) : '', projectNo: option ? option.projectNo : '', productName: option ? option.productName : '', materialCode: option ? option.materialCode : '', materialName: option ? option.materialName : '', rawMaterialBatchNo: rawMaterialBatchNo || '', rawMaterialBatchOptions: batchOptions, rawMaterialBatchIndex: batchIndexOf(batchOptions, rawMaterialBatchNo), rawMaterialBatchError: false, process: option ? option.process : '', stampingMethod: option ? option.stampingMethod : '', operatorCount: option ? option.operatorCount : 1, processUnitPriceYuan: option ? option.processUnitPriceYuan : 0, isContinuousDie: option ? !!option.isContinuousDie : false, isMultiPerson: option ? !!option.isMultiPerson : false, isMisc: option ? !!option.isMisc : false, standardBeat: option ? option.standardBeat : 0, standardWorkload: option ? option.standardWorkload : 0, changeoverCount: '', changeoverCountError: false, goodQty: '', defectQty: '', deviceError: false, } } const apiDraftItemToCamel = (item, options, startAt = '', equipmentOptions = []) => { const rawMaterialBatchOptions = item && item.raw_material_batch_options ? item.raw_material_batch_options : [] const rawMaterialBatchNo = item && item.raw_material_batch_no ? item.raw_material_batch_no : '' if (options.length >= 1 && (!item || !item.product_name)) { return optionToDraftItem( options[0], 0, startAt, equipmentOptions, item && item.device_no, rawMaterialBatchOptions, rawMaterialBatchNo, ) } const selectedDeviceNo = item.device_no || '' const productIndex = Math.max(0, options.findIndex(option => ( option.projectNo === item.project_no && option.productName === item.product_name && (option.process || '') === (item.process_name || '') ))) return { productIndex, attendancePointName: item.attendance_point_name || '', deviceNo: selectedDeviceNo, deviceIndex: selectedDeviceNo ? Math.max(0, equipmentOptions.findIndex(option => option.deviceNo === selectedDeviceNo)) : -1, startAt: item.started_at || startAt || '', startAtText: util.formatTime(item.started_at || startAt || ''), projectNo: item.project_no || '', productName: item.product_name || '', materialCode: item.material_code || '', materialName: item.material_name || '', rawMaterialBatchNo, rawMaterialBatchOptions, rawMaterialBatchIndex: batchIndexOf(rawMaterialBatchOptions, rawMaterialBatchNo), rawMaterialBatchError: false, process: item.process_name || '', stampingMethod: item.stamping_method || '', operatorCount: item.operator_count || 1, processUnitPriceYuan: item.process_unit_price_yuan || 0, isContinuousDie: !!item.is_continuous_die || isContinuousDieRow(item), isMultiPerson: isMultiPersonRow(item), isMisc: !!item.is_misc || isMiscRow(item), standardBeat: item.standard_beat || 0, standardWorkload: item.standard_workload || 0, changeoverCount: item.changeover_count === null || item.changeover_count === undefined ? '' : String(item.changeover_count || ''), changeoverCountError: false, goodQty: '', defectQty: '', deviceError: false, } } const toCamelDraft = row => ({ sessionId: row.session_id, attendancePointName: row.attendance_point_name || '', employeePhone: row.employee_phone, employeeName: row.employee_name, startAt: row.start_at, endAt: row.end_at, startAtText: util.formatTime(row.start_at), endAtText: util.formatTime(row.end_at), durationMinutes: row.duration_minutes, breakMinutes: 0, deviceBlocks: (row.device_blocks || []).map(block => { const options = (block.options || []).map(toCamelProduct) const equipmentOptions = (block.equipment_options || []).map(toCamelEquipment) const rawItems = block.items && block.items.length ? block.items : [{}] return { deviceNo: block.device_no, attendancePointName: block.attendance_point_name || row.attendance_point_name || '', moldName: block.mold_name || block.device_no, process: block.process_name || '', stampingMethod: block.stamping_method || '', moldDisplayName: block.display_name || moldDisplayName(block.mold_name || block.device_no, block.process_name, block.stamping_method), blockKey: `${block.device_no || ''}||${block.process_name || ''}`, isMisc: !!block.is_misc || isMiscRow({ product_name: block.mold_name || block.device_no, process_name: block.process_name }), isContinuousDie: !!block.is_continuous_die || isContinuousDieRow(block), startAt: block.start_at, endAt: block.end_at, durationMinutes: block.duration_minutes || 0, startAtText: util.formatTime(block.start_at), endAtText: util.formatTime(block.end_at), options, canAddItem: new Set(options.map(option => `${option.projectNo}||${option.productName}||${option.process}`)).size > 1, equipmentOptions, items: rawItems.map(item => apiDraftItemToCamel(item, options, block.start_at, equipmentOptions)), } }), }) const toSubmitPayload = draft => ({ session_id: Number(draft.sessionId), device_blocks: (draft.deviceBlocks || []).map(block => ({ attendance_point_name: block.attendancePointName || draft.attendancePointName || '', device_no: block.deviceNo, process_name: block.process || '', items: (block.items || []).map(item => ({ device_no: normalizeDeviceNo(item.deviceNo), attendance_point_name: item.attendancePointName || block.attendancePointName || draft.attendancePointName || '', project_no: item.projectNo, product_name: item.productName, material_code: item.materialCode || null, material_name: item.materialName || null, raw_material_batch_no: item.rawMaterialBatchNo || null, process_name: item.process || null, stamping_method: item.stampingMethod || null, operator_count: Number(item.operatorCount || 1), process_unit_price_yuan: Number(item.processUnitPriceYuan || 0), changeover_count: item.isContinuousDie ? Number(item.changeoverCount || 0) : 0, is_continuous_die: !!item.isContinuousDie, is_misc: !!item.isMisc, remark: item.remark || null, standard_beat: Number(item.standardBeat || 0), standard_workload: Number(item.standardWorkload || 0), good_qty: Number(item.goodQty || 0), defect_qty: Number(item.defectQty || 0), scrap_qty: 0, started_at: item.startAt || null, })), })), }) const getCurrentUser = () => { const auth = requestClient.getAuth() return auth && auth.user ? auth.user : null } const refreshCurrentUser = async () => { const auth = requestClient.getAuth() if (!auth || !auth.accessToken) { return null } const user = toCamelPerson(await requestClient.request({ url: '/api/auth/me', })) requestClient.setAuth({ ...auth, user }) return user } const saveAuth = data => { const auth = { accessToken: data.access_token, user: toCamelPerson(data.user), } requestClient.setAuth(auth) return auth.user } const normalizeLoginResult = data => { if (data.needs_temporary_worker) { return { needsTemporaryWorker: true, temporaryToken: data.temporary_token, temporaryPhone: data.temporary_phone, temporaryExpiresAt: data.temporary_expires_at || '', } } if (data.needs_selection) { return { needsSelection: true, selectionToken: data.selection_token, matchedPeople: (data.matched_people || []).map(toCamelPerson), } } return saveAuth(data) } const autoSelectDefaultRole = async result => { if (!result || !result.needsSelection) { return result } const role = pickDefaultRole(result.matchedPeople) if (!role || !result.selectionToken) { return result } return selectRole(result.selectionToken, role) } const loginWithPhoneCode = async (phoneCode, selectedRole = '') => { const data = await requestClient.request({ url: '/api/auth/wechat-login', method: 'POST', data: { phone_code: phoneCode, selected_role: selectedRole || null, }, }) return autoSelectDefaultRole(normalizeLoginResult(data)) } const toCamelErpLoginPreview = data => ({ sessionId: data.session_id, systemName: data.system_name || '嘉恒智能五金 ERP', startedAt: data.started_at || '', startedAtText: data.started_at ? util.formatTime(data.started_at) : '', expiresAt: data.expires_at || '', expiresAtText: data.expires_at ? util.formatTime(data.expires_at) : '', requestIpHint: data.request_ip_hint || '', userAgentHint: data.user_agent_hint || '', }) const toCamelErpLoginAction = data => ({ status: data.status || '', failureReason: data.failure_reason || '', }) const getErpLoginPreview = async (ticket, sessionId) => toCamelErpLoginPreview(await requestClient.request({ url: `/api/erp-login/sessions/${encodeURIComponent(ticket)}`, params: { session_id: sessionId, }, })) const confirmErpLogin = async (ticket, sessionId, phoneCode) => toCamelErpLoginAction(await requestClient.request({ url: `/api/erp-login/sessions/${encodeURIComponent(ticket)}/confirm`, method: 'POST', params: { session_id: sessionId, }, data: { phone_code: phoneCode, }, })) const cancelErpLogin = async (ticket, sessionId) => toCamelErpLoginAction(await requestClient.request({ url: `/api/erp-login/sessions/${encodeURIComponent(ticket)}/cancel`, method: 'POST', params: { session_id: sessionId, }, })) const createTemporaryWorker = async (temporaryToken, temporaryName = '', attendancePoint = null) => { const data = await requestClient.request({ url: '/api/auth/wechat-login', method: 'POST', data: { create_temporary_worker: true, temporary_token: temporaryToken, temporary_name: temporaryName, temporary_attendance_point_name: attendancePoint && attendancePoint.factory ? attendancePoint.factory.name : null, temporary_latitude: attendancePoint && attendancePoint.current ? attendancePoint.current.latitude : null, temporary_longitude: attendancePoint && attendancePoint.current ? attendancePoint.current.longitude : null, }, }) return normalizeLoginResult(data) } const selectRole = async (selectionToken, role) => saveAuth(await requestClient.request({ url: '/api/auth/select-role', method: 'POST', data: { selection_token: selectionToken, role, }, })) const switchRole = async role => saveAuth(await requestClient.request({ url: '/api/auth/switch-role', method: 'POST', data: { role, }, })) const logout = () => requestClient.clearAuth() const listAttendancePoints = async (filters = {}) => { const data = await requestClient.request({ url: '/api/attendance-points', params: { keyword: filters.keyword, page: filters.page || 1, page_size: filters.pageSize || 100, }, }) return toCamelPage(data, toCamelAttendancePoint) } const listAccessibleAttendancePoints = async () => { const data = await requestClient.request({ url: '/api/attendance-points/accessible', }) return (data || []).map(toCamelAttendancePoint) } const listPublicAttendancePoints = async () => { const data = await requestClient.request({ url: '/api/attendance-points/public', }) return (data || []).map(toCamelAttendancePoint) } const saveAttendancePoint = async payload => toCamelAttendancePoint(await requestClient.request({ url: '/api/attendance-points', method: 'POST', data: toSnakeAttendancePoint(payload), })) const deleteAttendancePoint = name => requestClient.request({ url: `/api/attendance-points/${encodeURIComponent(name)}`, method: 'DELETE', }) const listPeople = async (filters = {}) => { const data = await requestClient.request({ url: '/api/people', params: { keyword: filters.keyword, page: filters.page, page_size: filters.pageSize, }, }) return toCamelPage(data, toCamelPerson) } const savePerson = async payload => toCamelPerson(await requestClient.request({ url: '/api/people', method: 'POST', data: toSnakePerson(payload), })) const deletePerson = (phone, role = '') => requestClient.request({ url: `/api/people/${encodeURIComponent(phone)}`, method: 'DELETE', params: { role, }, }) const importPeople = filePath => requestClient.upload({ url: '/api/people/import', filePath, }) const exportPeople = () => requestClient.download({ url: '/api/people/export', }) const listProducts = async (filters = {}) => { const data = await requestClient.request({ url: '/api/products', params: { keyword: filters.keyword, attendance_point_name: filters.attendancePointName, device_no: filters.deviceNo, product_name: filters.productName, page: filters.page, page_size: filters.pageSize, }, }) return toCamelPage(data, toCamelProduct) } const listMoldNames = async (filters = {}) => { const data = await requestClient.request({ url: '/api/products/molds', params: { keyword: filters.keyword, limit: filters.limit || 200, }, }) return (data || []).map(toCamelMoldOption) } const saveProduct = async payload => toCamelProduct(await requestClient.request({ url: '/api/products', method: 'POST', data: toSnakeProduct(payload), })) const deleteProduct = product => requestClient.request({ url: '/api/products', method: 'DELETE', params: { attendance_point_name: product.attendancePointName, project_no: product.projectNo, product_name: product.productName, device_no: product.deviceNo, process_name: product.rawProcess || product.process, }, }) const importProducts = filePath => requestClient.upload({ url: '/api/products/import', filePath, }) const exportProducts = () => requestClient.download({ url: '/api/products/export', params: { t: Date.now() }, }) const listEquipment = async (filters = {}) => { const data = await requestClient.request({ url: '/api/equipment', params: { keyword: filters.keyword, attendance_point_name: filters.attendancePointName, device_type: filters.deviceType, page: filters.page, page_size: filters.pageSize, }, }) return toCamelPage(data, toCamelEquipment) } const saveEquipment = async payload => toCamelEquipment(await requestClient.request({ url: '/api/equipment', method: 'POST', data: toSnakeEquipment(payload), })) const deleteEquipment = equipment => { const deviceNo = typeof equipment === 'object' ? equipment.deviceNo : equipment const attendancePointName = typeof equipment === 'object' ? equipment.attendancePointName : '' return requestClient.request({ url: `/api/equipment/${encodeURIComponent(normalizeDeviceNo(deviceNo))}`, method: 'DELETE', params: { attendance_point_name: attendancePointName, }, }) } const listNotices = async (filters = {}) => { const data = await requestClient.request({ url: '/api/notices', params: { include_inactive: filters.includeInactive ? 1 : undefined, page: filters.page, page_size: filters.pageSize, }, }) return toCamelPage(data, toCamelNotice) } const saveNotice = async payload => { const noticeId = payload.id const data = await requestClient.request({ url: noticeId ? `/api/notices/${noticeId}` : '/api/notices', method: noticeId ? 'PUT' : 'POST', data: toSnakeNotice(payload), }) return toCamelNotice(data) } const deleteNotice = noticeId => requestClient.request({ url: `/api/notices/${noticeId}`, method: 'DELETE', }) const locationPayload = location => ({ latitude: location && location.latitude !== undefined ? location.latitude : undefined, longitude: location && location.longitude !== undefined ? location.longitude : undefined, }) const getClockState = async (moldName, processName = '', attendancePointName = '', location = null) => { const row = await requestClient.request({ url: '/api/clock/state', params: { mold_name: String(moldName || '').trim(), process_name: String(processName || '').trim(), attendance_point_name: String(attendancePointName || '').trim(), ...locationPayload(location), }, }) return { action: row.action, actionText: row.action_text, attendancePointName: row.attendance_point_name || '', deviceNo: row.device_no, moldName: row.mold_name || row.device_no, processName: row.process_name || '', stampingMethod: row.stamping_method || '', isCleaning: !!row.is_cleaning || isCleaningRow(row), isMisc: !!row.is_misc || isMiscRow(row), displayName: row.display_name || moldDisplayName(row.mold_name || row.device_no, row.process_name, row.stamping_method), currentMoldName: row.current_mold_name || '', currentProcessName: row.current_process_name || '', session: row.session_id ? { id: row.session_id } : null, startAtText: row.start_at ? util.formatTime(row.start_at) : '', endAtText: row.end_at ? util.formatTime(row.end_at) : '', devices: row.devices || [], autoSubmitHours: row.auto_submit_hours || 15, } } const startWork = async (moldName, processName = '', attendancePointName = '', location = null) => toCamelSession(await requestClient.request({ url: '/api/clock/start', method: 'POST', data: { mold_name: String(moldName || '').trim(), process_name: String(processName || '').trim(), attendance_point_name: String(attendancePointName || '').trim(), ...locationPayload(location), }, })) const switchDevice = async (moldName, processName = '', attendancePointName = '', location = null) => toCamelSession(await requestClient.request({ url: '/api/clock/switch-device', method: 'POST', data: { mold_name: String(moldName || '').trim(), process_name: String(processName || '').trim(), attendance_point_name: String(attendancePointName || '').trim(), ...locationPayload(location), }, })) const finishWork = async (moldName, processName = '', attendancePointName = '', location = null) => toCamelSession(await requestClient.request({ url: '/api/clock/finish', method: 'POST', data: { mold_name: String(moldName || '').trim(), process_name: String(processName || '').trim(), attendance_point_name: String(attendancePointName || '').trim(), ...locationPayload(location), }, })) const continueWork = async sessionId => toCamelSession(await requestClient.request({ url: '/api/clock/continue-work', method: 'POST', data: { session_id: Number(sessionId) }, })) const createMoldLockFeedback = async lockId => toCamelMoldLockFeedback(await requestClient.request({ url: '/api/monitor/feedbacks', method: 'POST', data: { lock_id: Number(lockId) }, })) const listMonitorLocks = async () => (await requestClient.request({ url: '/api/monitor/locks', })).map(toCamelMonitorLock) const listMoldLockFeedbacks = async (filters = {}) => toCamelPage(await requestClient.request({ url: '/api/monitor/feedbacks', params: { box: filters.box || 'unread', page: filters.page || 1, page_size: filters.pageSize || 10, }, }), toCamelMoldLockFeedback) const readMoldLockFeedback = async feedbackId => toCamelMoldLockFeedback(await requestClient.request({ url: `/api/monitor/feedbacks/${feedbackId}/read`, method: 'POST', })) const releaseMoldLock = async lockId => requestClient.request({ url: `/api/monitor/locks/${lockId}/release`, method: 'POST', }) const releaseMoldLockByFeedback = async feedbackId => toCamelMoldLockFeedback(await requestClient.request({ url: `/api/monitor/feedbacks/${feedbackId}/release`, method: 'POST', })) const buildReportDraft = async sessionId => toCamelDraft(await requestClient.request({ url: '/api/reports/draft', params: { session_id: sessionId }, })) const submitReport = async draft => toCamelReport(await requestClient.request({ url: '/api/reports', method: 'POST', data: toSubmitPayload(draft), })) const submitCleaningReport = async (items, location = null) => toCamelReport(await requestClient.request({ url: '/api/reports/cleaning', method: 'POST', data: { ...locationPayload(location), items: (items || []).map(item => ({ attendance_point_name: item.attendancePointName || '', device_no: normalizeDeviceNo(item.deviceNo), device_nos: item.deviceNos && item.deviceNos.length ? item.deviceNos.map(normalizeDeviceNo).filter(Boolean) : [], product_name: item.moldName || item.productName || item.name || '', process_name: item.processName || '', stamping_method: item.stampingMethod || '', quantity: Number(item.quantity || 0), })), }, })) const getReport = async reportId => toCamelReport(await requestClient.request({ url: `/api/reports/${reportId}`, })) const listReports = async (filters = {}) => { let url = '/api/reports/mine' if (filters.status === 'pending' && !filters.employeePhone) { url = '/api/reviews/pending' } if (filters.reviewerPhone && url !== '/api/reviews/pending') { url = '/api/reviews/mine' } const params = { start_date: filters.startDate, end_date: filters.endDate, page: filters.page, page_size: filters.pageSize, include_voided: filters.includeVoided ? 1 : undefined, } if (url === '/api/reports/mine' && filters.status) { params.status = filters.status } if (url === '/api/reviews/mine' && filters.status) { params.status = filters.status } const data = await requestClient.request({ url, params }) return toCamelPage(data, toCamelReport) } const approveReport = async (reportId, user, patch = {}) => toCamelReport(await requestClient.request({ url: `/api/reviews/${reportId}/approve`, method: 'POST', data: { start_at: patch.startAt || null, end_at: patch.endAt || null, device_corrections: (patch.deviceCorrections || []).map(item => ({ id: item.id, scanned_at: item.scannedAt || null, })), item_corrections: patch.itemCorrections || [], review_remark: Object.prototype.hasOwnProperty.call(patch, 'reviewRemark') ? (patch.reviewRemark || null) : undefined, remark: patch.remark || null, }, })) const voidReport = async reportId => toCamelReport(await requestClient.request({ url: `/api/reviews/${reportId}/void`, method: 'POST', })) const unvoidReport = async reportId => toCamelReport(await requestClient.request({ url: `/api/reviews/${reportId}/unvoid`, method: 'POST', })) const toCamelWorkSchedule = row => ({ id: row.id, dayStart: row.day_start, dayEnd: row.day_end, lunchStart: row.lunch_start, lunchEnd: row.lunch_end, dinnerStart: row.dinner_start, dinnerEnd: row.dinner_end, overtimeStart: row.overtime_start, overtimeEnd: row.overtime_end, nightStart: row.night_start, nightEnd: row.night_end, attendanceLatitude: row.attendance_latitude === null || row.attendance_latitude === undefined ? '' : row.attendance_latitude, attendanceLongitude: row.attendance_longitude === null || row.attendance_longitude === undefined ? '' : row.attendance_longitude, attendanceRadiusMeters: row.attendance_radius_meters === null || row.attendance_radius_meters === undefined ? '' : row.attendance_radius_meters, autoSubmitHours: row.auto_submit_hours === null || row.auto_submit_hours === undefined ? 15 : row.auto_submit_hours, attendancePoints: (row.attendance_points || []).map(toCamelAttendancePoint), updatedBy: row.updated_by || '', updatedAt: row.updated_at || '', updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', }) const toSnakeWorkSchedule = row => ({ day_start: row.dayStart, day_end: row.dayEnd, lunch_start: row.lunchStart, lunch_end: row.lunchEnd, dinner_start: row.dinnerStart, dinner_end: row.dinnerEnd, overtime_start: row.overtimeStart, overtime_end: row.overtimeEnd, night_start: row.nightStart, night_end: row.nightEnd, attendance_latitude: row.attendanceLatitude === '' || row.attendanceLatitude === undefined ? null : Number(row.attendanceLatitude), attendance_longitude: row.attendanceLongitude === '' || row.attendanceLongitude === undefined ? null : Number(row.attendanceLongitude), attendance_radius_meters: row.attendanceRadiusMeters === '' || row.attendanceRadiusMeters === undefined ? null : Number(row.attendanceRadiusMeters), auto_submit_hours: row.autoSubmitHours === '' || row.autoSubmitHours === undefined ? 15 : Number(row.autoSubmitHours), }) const getWorkSchedule = async () => toCamelWorkSchedule(await requestClient.request({ url: '/api/work-schedule', })) const saveWorkSchedule = async payload => toCamelWorkSchedule(await requestClient.request({ url: '/api/work-schedule', method: 'PUT', data: toSnakeWorkSchedule(payload), })) const saveWorkScheduleSettings = async payload => toCamelWorkSchedule(await requestClient.request({ url: '/api/work-schedule/settings', method: 'PUT', data: { ...toSnakeWorkSchedule(payload), attendance_points: (payload.attendancePoints || []).map(toSnakeAttendancePoint), }, })) const listDashboard = async (filters = {}) => { const data = await requestClient.request({ url: '/api/dashboard/reports', params: { start_date: filters.startDate, end_date: filters.endDate, page: filters.page, page_size: filters.pageSize, include_voided: filters.includeVoided ? 1 : undefined, }, }) return toCamelPage(data, row => { const isMisc = !!row.is_misc || isMiscRow(row) const noOutputMetric = isMisc || isCleaningRow(row) const beatComparison = noOutputMetric ? classifyBeat(0, 0) : classifyBeat(row.actual_beat, row.standard_beat) const workloadComparison = noOutputMetric ? classifyWorkload(0, 0) : classifyWorkload(row.total_good_qty, row.expected_workload) return { id: row.id, attendancePointName: row.attendance_point_name || '', reportDate: row.report_date, employeePhone: row.employee_phone, employeeName: row.employee_name, projectNo: row.project_no, productName: row.product_name, processName: row.process_name, processDisplayName: moldDisplayName(row.product_name, row.process_name, row.stamping_method), rawMaterialBatchNo: row.raw_material_batch_no || '', stampingMethod: row.stamping_method || '', isCleaning: isCleaningRow(row), isContinuousDie: isContinuousDieRow(row), changeoverCount: row.changeover_count || 0, isMisc, isSystemAutoSubmitted: !!row.is_system_auto_submitted, isVoided: !!row.is_voided, isMultiPerson: isMultiPersonRow(row), isMultiPersonAssistant: !!row.is_multi_person_assistant, workerType: row.worker_type || '正式工', reportCount: row.report_count, effectiveMinutes: row.effective_minutes, shiftDistributionText: row.shift_distribution_text || '', sourceReportDate: row.source_report_date || '', sourceReportDates: row.source_report_dates || [], sourceReportDatesText: (row.source_report_dates || []).join('、'), shiftDayMinutes: row.shift_day_minutes || 0, shiftOvertimeMinutes: row.shift_overtime_minutes || 0, shiftNightMinutes: row.shift_night_minutes || 0, shiftOtherMinutes: row.shift_other_minutes || 0, totalGood: row.total_good_qty, totalDefect: row.total_defect_qty, totalOutput: row.total_output_qty, actualBeat: row.actual_beat, standardBeat: row.standard_beat, processUnitPriceYuan: row.process_unit_price_yuan || 0, referenceWage: round2(row.reference_wage || 0), expectedWorkload: row.expected_workload, paceRate: row.pace_rate, workloadRate: row.workload_rate, paceText: row.pace_text, workloadText: row.workload_text, beatCompareClass: beatComparison.className, beatReason: beatComparison.reason, workloadCompareClass: workloadComparison.className, workloadReason: workloadComparison.reason, reviewRemark: row.review_remark || '', correctionPairs: (row.correction_pairs || []).map(pair => ({ key: pair.key, label: pair.label, oldValue: correctionDisplayValue(pair.old_value), newValue: correctionDisplayValue(pair.new_value), })), } }) } const listUsageStats = async (filters = {}) => { const data = await requestClient.request({ url: '/api/usage-stats/summary', params: { ...usageStatsParams(filters), page: filters.page, page_size: filters.pageSize, }, }) return toCamelPage(data, toCamelUsageStatsRow) } const getUsageStatsDetail = async (filters = {}) => toCamelUsageStatsDetail(await requestClient.request({ url: '/api/usage-stats/detail', params: usageStatsParams(filters), })) const listReconciliationYears = async () => { const data = await requestClient.request({ url: '/api/reconciliation/years', }) return { years: data.years || [], currentYear: data.current_year, } } const listReconciliationLedger = async year => { const data = await requestClient.request({ url: '/api/reconciliation', params: { year }, }) return { year: data.year, currentYear: data.current_year, currentMonth: data.current_month, rows: (data.rows || []).map(row => ({ attendancePointName: row.attendance_point_name || '', productName: row.product_name, uniqueKey: `${row.attendance_point_name || ''}||${row.product_name || ''}`, months: (row.months || []).map(month => ({ month: month.month, reportedGoodQty: month.reported_good_qty || 0, reconciledGoodQty: month.reconciled_good_qty || 0, returnQty: month.return_qty || 0, })), })), } } const saveReconciliationEntry = async payload => { const data = await requestClient.request({ url: '/api/reconciliation/entry', method: 'PUT', data: { year: Number(payload.year), month: Number(payload.month), attendance_point_name: payload.attendancePointName || '', product_name: payload.productName, reconciled_good_qty: payload.reconciledGoodQty === undefined ? undefined : Number(payload.reconciledGoodQty || 0), return_qty: payload.returnQty === undefined ? undefined : Number(payload.returnQty || 0), }, }) return { month: data.month, reconciledGoodQty: data.reconciled_good_qty || 0, returnQty: data.return_qty || 0, } } const exportDashboard = filters => requestClient.download({ url: '/api/dashboard/reports/export', params: { start_date: filters && filters.startDate, end_date: filters && filters.endDate, include_voided: filters && filters.includeVoided ? 1 : undefined, }, }) const exportUsageStats = (filters = {}) => requestClient.download({ url: '/api/usage-stats/export', params: usageStatsParams(filters), }) const generateMoldQr = async mold => { const original = typeof mold === 'object' ? String(mold.moldName || mold.name || '').trim() : String(mold || '').trim() const processName = typeof mold === 'object' ? String(mold.processName || '').trim() : '' const attendancePointName = typeof mold === 'object' ? String(mold.attendancePointName || '').trim() : '' const data = await requestClient.request({ url: '/api/devices/molds/qrcode', method: 'POST', data: { attendance_point_name: attendancePointName, product_name: original, process_name: processName, }, }) return { deviceNo: data.device_no, attendancePointName: data.attendance_point_name || '', moldName: data.mold_name || data.device_no, processName: data.process_name || '', stampingMethod: data.stamping_method || '', isCleaning: !!data.is_cleaning || isCleaningRow(data), isMisc: !!data.is_misc || isMiscRow(data), displayName: data.display_name || moldDisplayName(data.mold_name || data.device_no, data.process_name, data.stamping_method), qrPath: `${data.page}?scene=${encodeURIComponent(data.scene)}`, qrUrl: data.qr_url || '', scene: data.scene, } } const generateMoldQrBatch = async molds => { const data = await requestClient.request({ url: '/api/devices/molds/qrcode/batch', method: 'POST', timeout: 120000, data: { items: (molds || []).map(mold => ({ attendance_point_name: String(mold.attendancePointName || '').trim(), product_name: String(mold.moldName || mold.name || '').trim(), process_name: String(mold.processName || '').trim(), })), }, }) return { count: data.count || 0, fileName: data.file_name || '', zipUrl: data.zip_url || '', } } const toCamelQrcodeBatchTask = row => ({ id: row.id, fileName: row.file_name || '', status: row.status || '', statusName: row.status_name || '', itemCount: Number(row.item_count || 0), completedCount: Number(row.completed_count || 0), failedCount: Number(row.failed_count || 0), progressPercent: Number(row.progress_percent || 0), zipUrl: row.zip_url || '', errorMessage: row.error_message || '', startedAt: row.started_at || '', startedAtText: row.started_at ? util.formatTime(row.started_at) : '', finishedAt: row.finished_at || '', finishedAtText: row.finished_at ? util.formatTime(row.finished_at) : '', createdAt: row.created_at || '', createdAtText: row.created_at ? util.formatTime(row.created_at) : '', updatedAt: row.updated_at || '', isPending: row.status === 'pending', isRunning: row.status === 'running', isPaused: row.status === 'paused', isCompleted: row.status === 'completed', isFailed: row.status === 'failed', }) const createMoldQrBatchTask = async molds => { const data = await requestClient.request({ url: '/api/devices/molds/qrcode/batch/tasks', method: 'POST', timeout: 30000, data: { items: (molds || []).map(mold => ({ attendance_point_name: String(mold.attendancePointName || '').trim(), product_name: String(mold.moldName || mold.name || '').trim(), process_name: String(mold.processName || '').trim(), })), }, }) return toCamelQrcodeBatchTask(data) } const listMoldQrBatchTasks = async (filters = {}) => { const page = await requestClient.request({ url: '/api/devices/molds/qrcode/batch/tasks', params: { page: filters.page || 1, page_size: filters.pageSize || 5, }, }) return toCamelPage(page, toCamelQrcodeBatchTask) } const stopMoldQrBatchTask = async taskId => toCamelQrcodeBatchTask(await requestClient.request({ url: `/api/devices/molds/qrcode/batch/tasks/${taskId}/stop`, method: 'POST', })) const resumeMoldQrBatchTask = async taskId => toCamelQrcodeBatchTask(await requestClient.request({ url: `/api/devices/molds/qrcode/batch/tasks/${taskId}/resume`, method: 'POST', })) const deleteMoldQrBatchTask = async taskId => requestClient.request({ url: `/api/devices/molds/qrcode/batch/tasks/${taskId}`, method: 'DELETE', }) const downloadFileByUrl = (url, filePath, timeout = 120000) => requestClient.downloadUrl({ url, filePath, timeout, }) const generateDeviceQr = generateMoldQr const resolveMoldScene = async scene => { const data = await requestClient.request({ url: '/api/devices/resolve-scene', params: { scene }, }) return { attendancePointName: data.attendance_point_name || '', moldName: data.mold_name || data.device_no || '', processName: data.process_name || '', stampingMethod: data.stamping_method || '', isCleaning: !!data.is_cleaning || isCleaningRow(data), isMisc: !!data.is_misc || isMiscRow(data), displayName: data.display_name || moldDisplayName(data.mold_name || data.device_no, data.process_name, data.stamping_method), } } const getHomeSummary = async user => { if (!user) { return {} } const summary = { productCount: 0, peopleCount: 0, pendingCount: 0, myToday: 0, approvedToday: 0, } const today = util.today() const tasks = [ listProducts({ page: 1, pageSize: 1 }).then(result => { summary.productCount = result.total }), listReports({ startDate: today, endDate: today, page: 1, pageSize: 1 }).then(result => { summary.myToday = result.total }).catch(() => {}), ] if (['admin', 'manager'].includes(user.role)) { tasks.push(listPeople({ page: 1, pageSize: 1 }).then(result => { summary.peopleCount = result.total }).catch(() => {})) } if (user.role === 'admin') { tasks.push(listReports({ status: 'pending', page: 1, pageSize: 1, }).then(result => { summary.pendingCount = result.total }).catch(() => {})) tasks.push(listReports({ reviewerPhone: user.phone, status: 'approved', startDate: today, endDate: today, page: 1, pageSize: 1, }).then(result => { summary.approvedToday = result.total }).catch(() => {})) } if (user.role === 'manager') { tasks.push(listDashboard({ startDate: today, endDate: today, page: 1, pageSize: 1 }).then(result => { summary.approvedToday = result.total }).catch(() => {})) } await Promise.all(tasks) return summary } module.exports = { approveReport, buildReportDraft, calculateStandardWorkload, cancelErpLogin, classifyBeat, classifyWorkload, confirmErpLogin, continueWork, createTemporaryWorker, createMoldLockFeedback, createMoldQrBatchTask, deleteMoldQrBatchTask, deleteAttendancePoint, deleteEquipment, deletePerson, deleteProduct, deleteNotice, downloadFileByUrl, exportPeople, exportProducts, exportDashboard, exportUsageStats, formatDurationAllUnits, formatDurationMinutes, finishWork, generateDeviceQr, generateMoldQr, generateMoldQrBatch, getClockState, getCurrentUser, getErpLoginPreview, getHomeSummary, getReport, getUsageStatsDetail, getWorkSchedule, importPeople, importProducts, listAccessibleAttendancePoints, listAttendancePoints, listDashboard, listEquipment, listMoldNames, listMonitorLocks, listMoldLockFeedbacks, listMoldQrBatchTasks, listNotices, listPeople, listProducts, listPublicAttendancePoints, listReconciliationLedger, listReconciliationYears, listReports, listUsageStats, loginWithPhoneCode, logout, isContinuousDieRow, isContinuousDieStampingMethod, moldDisplayName, normalizeDeviceNo, normalizeProcessValue, processDisplayName, refreshCurrentUser, readMoldLockFeedback, releaseMoldLock, releaseMoldLockByFeedback, resolveMoldScene, roleNames, saveAttendancePoint, saveNotice, saveEquipment, savePerson, saveProduct, saveReconciliationEntry, saveWorkSchedule, saveWorkScheduleSettings, selectRole, startWork, resumeMoldQrBatchTask, stopMoldQrBatchTask, submitCleaningReport, submitReport, switchRole, switchDevice, unvoidReport, voidReport, }