Compare commits

..

5 Commits

Author SHA1 Message Date
tzy
5e20f8868a 页面修改 2026-08-27 10:58:25 +08:00
tzy
5357434d9d merge: pull remote master and keep local dispatch qty with refill support
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 10:55:54 +08:00
tzy
d4f2465fc6 merge: pull remote master and keep local dispatch remain qty logic
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:20:53 +08:00
tzy
2762769bc8 Merge branch 'master' of https://git.evo-techina.com/sgc/mes-vue 2026-08-22 15:50:01 +08:00
tzy
c8b41fffaa 设备台账补齐新字段,甘特排产支持指定设备
设备维护增加系统品牌、网口、位置和 IP;排产甘特图可选择设备并展示未完成任务。派工待派数量按已派工计算,兼容补料。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 08:49:15 +08:00
27 changed files with 3058 additions and 660 deletions

227
package-lock.json generated
View File

@ -23,6 +23,8 @@
"naive-ui": "^2.37.3",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"qrcode": "^1.5.4",
"seemly": "^0.3.10",
"three": "^0.184.0",
"vue": "^3.4.15",
"vue-draggable-plus": "^0.6.1",
@ -30,6 +32,7 @@
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.184.1",
"@vitejs/plugin-vue": "^5.0.3",
"electron": "^42.5.0",
@ -1739,6 +1742,16 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -2043,7 +2056,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -2053,7 +2065,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -2502,6 +2513,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
@ -2620,7 +2640,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -2633,7 +2652,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@ -2823,6 +2841,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@ -2933,6 +2960,12 @@
"resolved": "https://registry.npmjs.org/dhtmlx-gantt/-/dhtmlx-gantt-9.1.4.tgz",
"integrity": "sha512-XCNA5QUiuV79Xq1ykNpH9LFNR2IVpDZMqnmBV6dsBeOkHyPMOpkyQ/gqAPCcK2GAvYHoN2nGAMYb2LldCWhMuQ=="
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dingtalk-jsapi": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/dingtalk-jsapi/-/dingtalk-jsapi-3.2.9.tgz",
@ -3242,7 +3275,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
@ -3503,6 +3535,19 @@
"node": ">=10"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
@ -3589,7 +3634,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@ -3980,7 +4024,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4169,6 +4212,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
@ -4596,6 +4651,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
@ -4619,6 +4710,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@ -4764,6 +4864,15 @@
"node": ">=10.4.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
@ -4925,6 +5034,89 @@
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@ -5002,7 +5194,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -5018,6 +5209,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resedit": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@ -5247,6 +5444,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5362,7 +5565,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@ -5377,7 +5579,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@ -5922,6 +6123,12 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -27,6 +27,7 @@
"naive-ui": "^2.37.3",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"qrcode": "^1.5.4",
"seemly": "^0.3.10",
"three": "^0.184.0",
"vue": "^3.4.15",
@ -35,6 +36,7 @@
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.184.1",
"@vitejs/plugin-vue": "^5.0.3",
"electron": "^42.5.0",

View File

@ -16,6 +16,10 @@ export interface AssingWorkDetail {
createTime?: string
pauseSeconds?: number
shuntSeconds?: number
actualWorkSeconds?: number
}
// AssingWorkDetail API

View File

@ -11,13 +11,21 @@ export interface Device{
sectionName?:string,
spec:string,
manufacturer:string,
manufactureDate:string,
manufactureDate?: string | number | null,
workshopId:number,
remark:string,
status:number,
deviceFlag?:string,
/** 新代 SynFactory equipID */
synEquipId?: number | null,
/** 系统品牌 */
systemBrand?: string | null,
/** 是否有网口 0否 1是 */
hasNetworkPort?: number | null,
/** 位置 */
location?: string | null,
/** IP */
ip?: string | null,
createTime:string,
updateTime:string
abnormalRecordList:[],
@ -78,7 +86,47 @@ export interface DeviceRealtimeVO {
workshopName?: string
}
export interface DeviceScheduleItemVO {
id: number
deviceCode?: string
deviceName?: string
sectionName?: string
deviceFlag?: string
deviceFlagLabel?: string
runStatus?: 'busy' | 'idle' | string
runStatusLabel?: string
loadPercent?: number
taskCount?: number
}
export interface DeviceScheduleCapableVO {
sectionId?: number | null
sectionName?: string | null
matchMode?: string
matchHint?: string
devices?: DeviceScheduleItemVO[]
}
export interface DeviceScheduleTaskVO {
id?: number | string
deviceId?: string
orderNo?: string
materialCode?: string
materialName?: string
processName?: string
operNumber?: number | string | null
quantity?: number | null
assingStatus?: number | null
pauseStatus?: number | null
statusKey?: string
statusText?: string
statusLabel?: string
planStartTime?: string | null
planFinishTime?: string | null
locked?: boolean
isCurrent?: boolean
durationMs?: number
}
export interface DocumentEntity {
id?:number,
@ -192,5 +240,23 @@ export const deviceApi = {
url:`/biz/device/getByid/${deviceId}`,
method:"get"
})
},
/** 按工序工作中心匹配可执行设备/工位 */
scheduleCapable(params: { workCenterName?: string | null; departmentName?: string | null }) {
return request<DeviceScheduleCapableVO>({
url: '/biz/device/schedule-capable',
method: 'get',
params
})
},
/** 指定设备当前未完成任务 */
scheduleTasks(deviceId: number) {
return request<DeviceScheduleTaskVO[]>({
url: '/biz/device/schedule-tasks',
method: 'get',
params: { deviceId }
})
}
}

View File

@ -102,10 +102,11 @@ export function exportNccode(params:any) {
})
}
//加载全部NC代码
export function loadAllNcCode(id:number) {
// 加载 NC传设备 id 时按设备查绑定文档;不传则拉全部 NC 代码库
export function loadAllNcCode(deviceId?: string | number) {
const id = deviceId != null && deviceId !== '' ? `/${deviceId}` : ''
return request({
url: `biz/ncCode/loadAllNcCode/${id}`,
url: `/biz/ncCode/loadAllNcCode${id}`,
method: 'get'
})
}

View File

@ -349,6 +349,20 @@ export const orderProcessPlanApi = {
orderItemId?: number | string
/** 物料名称 */
materialName?: string
orderItemName?: string
/** 生产令号 */
mainCode?: string
/** 物料编码 */
materialCode?: string
/** 车间 id */
workshopId?: number | string | null
}) {
return request<{ list: OrderProcessPlanVO[]; total: number; page: number; pageSize: number }>({

View File

@ -93,6 +93,10 @@ export interface KingdeeProcessRoute {
qualityInspection?: number | null
/** 是否入库 1是 0否后端仅末序为 1 */
storageEntry?: number | null
/** 排产指定设备/工位 */
deviceId?: number | string | null
deviceCode?: string | null
deviceName?: string | null
}
/** 解析工序名称,兼容旧字段及后端误映射 */

View File

@ -30,6 +30,21 @@ export interface Outsourcing {
issuedQuantity?: number | string
receivedQuantity?: number | string
qualifiedQuantity?: number | string
/** 0未下推 1已下推 */
kingdeePushed?: number
kingdeeBillNo?: string
kingdeePushTime?: string
kingdeePushBy?: string
details?: OutsourcingDetail[]
}
export interface OutsourcingDetail {
id?: number
outsourcingId?: number
flowingAroundId?: number
quantity?: number
remark?: string
createTime?: string
}
export const OUTSOURCE_STATUS_MAP: Record<number, { label: string; type: 'default' | 'info' | 'success' | 'warning' | 'error' }> = {
@ -67,6 +82,14 @@ export const outsourcingApi = {
return request<Outsourcing>({ url: `/biz/outsourcing/${id}`, method: 'get' })
},
details(id: number | string) {
return request<OutsourcingDetail[]>({ url: `/biz/outsourcing/${id}/details`, method: 'get' })
},
pushKingdee(id: number | string) {
return request({ url: `/biz/outsourcing/${id}/pushKingdee`, method: 'post' })
},
create(data: Outsourcing) {
return request({ url: '/biz/outsourcing', method: 'post', data })
},

View File

@ -16,6 +16,10 @@ export interface AssingWork {
status:number,
reason:string,
pauseTime:Date,
pauseSeconds?: number,
shuntSeconds?: number,
actualWorkSeconds?: number,
unitWorkSeconds?: number,
assingStatus:number,
assingCode:string,
parentId:number,

View File

@ -24,6 +24,10 @@ export interface SubmitLog {
remark?: string
pauseSeconds?: number
shuntSeconds?: number
actualWorkSeconds?: number
}
// 派工工单汇报记录 API

View File

@ -13,3 +13,15 @@ export function getaDtetime(timestamp:any) {
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
/** 工时秒数格式化为 小时/分/秒 */
export function formatWorkSeconds(seconds?: number | string | null) {
const n = Number(seconds)
if (!Number.isFinite(n) || n < 0) return '0秒'
const h = Math.floor(n / 3600)
const m = Math.floor((n % 3600) / 60)
const s = Math.floor(n % 60)
if (h > 0) return `${h}小时${m}${s}`
if (m > 0) return `${m}${s}`
return `${s}`
}

View File

@ -73,7 +73,7 @@
:data="tableData"
:loading="loading"
:row-key="(row) => row.id"
:scroll-x="1200"
:scroll-x="1800"
v-model:checked-row-keys="selectedIds"
/>
<div class="pagination-container" style="display: flex; justify-content: flex-end; margin-top: 12px">
@ -96,8 +96,8 @@
</n-card>
<!-- 新增/编辑弹窗 -->
<n-modal v-model:show="modalVisible" preset="card" :title="modalTitle" style="width: 600px">
<n-form ref="formRef" :model="formData" :rules="formRules" label-placement="left" label-width="100px">
<n-modal v-model:show="modalVisible" preset="card" :title="modalTitle" style="width: 640px">
<n-form ref="formRef" :model="formData" :rules="formRules" label-placement="left" label-width="110px">
<n-form-item label="设备名称" path="deviceName">
<n-input v-model:value="formData.deviceName" placeholder="请输入设备名称"/>
</n-form-item>
@ -143,8 +143,23 @@
<n-form-item label="生产厂家" path="manufacturer">
<n-input v-model:value="formData.manufacturer" placeholder="请输入生产厂家"/>
</n-form-item>
<n-form-item label="系统品牌" path="systemBrand">
<n-input v-model:value="formData.systemBrand" placeholder="请输入系统品牌"/>
</n-form-item>
<n-form-item label="网口" path="hasNetworkPort">
<n-switch v-model:value="formData.hasNetworkPort" :checked-value="1" :unchecked-value="0">
<template #checked></template>
<template #unchecked></template>
</n-switch>
</n-form-item>
<n-form-item label="出厂日期" path="manufactureDate">
<n-date-picker v-model:value="formData.manufactureDate" type="datetime" clearable style="width: 100%"/>
<n-date-picker v-model:value="formData.manufactureDate" type="month" clearable style="width: 100%"/>
</n-form-item>
<n-form-item label="位置" path="location">
<n-input v-model:value="formData.location" placeholder="请输入位置"/>
</n-form-item>
<n-form-item label="IP" path="ip">
<n-input v-model:value="formData.ip" placeholder="请输入IP地址"/>
</n-form-item>
<n-form-item label="备注" path="remark">
<n-input v-model:value="formData.remark" type="textarea" placeholder="请输入备注"/>
@ -445,6 +460,10 @@ const defaultFormData: Device = {
spec: '',
manufacturer: '',
manufactureDate: undefined,
systemBrand: '',
hasNetworkPort: 0,
location: '',
ip: '',
workshopId: '',
remark: '',
status: 0,
@ -497,7 +516,24 @@ const columns: DataTableColumns<Device> = [
{title: '所属工段', key: 'sectionName'},
{title: '设备型号', key: 'spec'},
{title: '生产厂家', key: 'manufacturer'},
{title: '出厂日期', key: 'manufactureDate'},
{title: '系统品牌', key: 'systemBrand', width: 110,
render: (row) => row.systemBrand || '-'
},
{title: '网口', key: 'hasNetworkPort', width: 80,
render: (row) => {
const yes = Number(row.hasNetworkPort) === 1
return h(NTag, {type: yes ? 'success' : 'default', size: 'small'}, {default: () => yes ? '是' : '否'})
}
},
{title: '出厂日期', key: 'manufactureDate', width: 110,
render: (row) => formatYearMonth(row.manufactureDate)
},
{title: '位置', key: 'location', width: 120,
render: (row) => row.location || '-'
},
{title: 'IP', key: 'ip', width: 130,
render: (row) => row.ip || '-'
},
{
title: '状态', key: 'status',
render: (row) => {
@ -650,11 +686,29 @@ function goRealtimeBoard() {
router.push('/biz/device/board')
}
function formatYearMonth(val?: string | number | null) {
if (val == null || val === '') return '-'
const s = String(val)
if (/^\d{4}-\d{2}/.test(s)) return s.slice(0, 7)
const d = new Date(typeof val === 'number' ? val : s)
if (Number.isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function toMonthTimestamp(val: any): number | null {
if (val == null || val === '') return null
if (typeof val === 'number') return val
const d = new Date(String(val).replace(' ', 'T'))
return Number.isNaN(d.getTime()) ? null : d.getTime()
}
//
function handleEdit(row: Device) {
Object.assign(formData, defaultFormData)
modalTitle.value = '编辑设备表'
Object.assign(formData, row)
formData.manufactureDate = toMonthTimestamp(row.manufactureDate) as any
formData.hasNetworkPort = Number(row.hasNetworkPort) === 1 ? 1 : 0
const userIdList = []
if (row.deviceManager) {
let manager = row.deviceManager.split(',');
@ -674,7 +728,9 @@ async function handleSubmit() {
const submitData = {...formData} as Device
if (typeof submitData.manufactureDate === 'number') {
submitData.manufactureDate = new Date(submitData.manufactureDate).toISOString().slice(0, 19).replace('T', ' ')
const d = new Date(submitData.manufactureDate)
const month = String(d.getMonth() + 1).padStart(2, '0')
submitData.manufactureDate = `${d.getFullYear()}-${month}-01 00:00:00`
}
if (submitData.id) {
await deviceApi.update(submitData)

View File

@ -1,36 +1,36 @@
<template>
<div class="plan-board-page">
<div class="page-container plan-board-page">
<!-- 顶部工具栏 -->
<div class="board-toolbar">
<n-space :size="8" align="center">
<n-button type="primary" size="small" @click="handleAdd">
<n-space :size="10" align="center">
<n-button type="primary" @click="handleAdd">
<template #icon><n-icon><AddOutline /></n-icon></template>
新增工序
</n-button>
<n-button size="small" type="success" ghost disabled title="待后端接口支持">
<n-button type="success" ghost disabled title="待后端接口支持">
<template #icon><n-icon><CheckmarkCircleOutline /></n-icon></template>
齐套自动下达
</n-button>
<n-button size="small" type="error" ghost disabled title="待后端接口支持">批量删除</n-button>
<n-button size="small" type="primary" ghost disabled title="待后端接口支持">批量下达</n-button>
<n-button size="small" quaternary disabled title="待后端接口支持">合并为组工单</n-button>
<n-button type="error" ghost disabled title="待后端接口支持">批量删除</n-button>
<n-button type="primary" ghost disabled title="待后端接口支持">批量下达</n-button>
<n-button quaternary disabled title="待后端接口支持">合并为组工单</n-button>
<n-dropdown :options="batchOptions" trigger="click" @select="onBatchSelect">
<n-button size="small" quaternary>
<n-button quaternary>
批量操作
<n-icon style="margin-left: 4px"><ChevronDownOutline /></n-icon>
</n-button>
</n-dropdown>
</n-space>
<n-space :size="8" align="center">
<n-button size="small" quaternary @click="importModalVisible = true">
<n-space :size="10" align="center">
<n-button quaternary @click="importModalVisible = true">
<template #icon><n-icon><CloudUploadOutline /></n-icon></template>
导入
</n-button>
<n-button size="small" quaternary @click="handleExport">
<n-button quaternary @click="handleExport">
<template #icon><n-icon><DownloadOutline /></n-icon></template>
导出
</n-button>
<n-button size="small" quaternary @click="loadData">
<n-button quaternary @click="loadData">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
同步
</n-button>
@ -39,20 +39,36 @@
<!-- 筛选 -->
<div class="board-filter">
<n-form inline :model="searchForm" label-placement="left" size="small">
<n-form-item label="工序名称">
<n-input v-model:value="searchForm.name" placeholder="工序名称" clearable style="width: 140px" />
<n-form inline :model="searchForm" label-placement="left" label-width="72">
<n-form-item label="生产令号">
<n-input v-model:value="searchForm.mainCode" placeholder="生产令号" clearable style="width: 150px" />
</n-form-item>
<n-form-item label="工序编码">
<n-input v-model:value="searchForm.code" placeholder="工序编码" clearable style="width: 140px" />
<n-form-item label="物料编码">
<n-input v-model:value="searchForm.materialCode" placeholder="物料编码" clearable style="width: 150px" />
</n-form-item>
<n-form-item label="物料名称">
<n-input v-model:value="searchForm.materialName" placeholder="物料名称" clearable style="width: 150px" />
</n-form-item>
<n-form-item label="车间">
<n-select
v-model:value="searchForm.workshopId"
:options="workshopOptions"
placeholder="请选择车间"
clearable
filterable
style="width: 150px"
/>
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" size="small" @click="handleSearch">
<n-button type="primary" @click="handleSearch">
<template #icon><n-icon><SearchOutline /></n-icon></template>
搜索
</n-button>
<n-button size="small" @click="handleReset">重置</n-button>
<n-button @click="handleReset">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
重置
</n-button>
</n-space>
</n-form-item>
</n-form>
@ -62,7 +78,8 @@
<!-- 表头 -->
<div class="board-table-head">
<div class="col-check" />
<div class="col-product">产品 / 工单编号</div>
<div class="col-main-code">生产令号</div>
<div class="col-product">物料名称 / 编码</div>
<div class="col-qty">数量</div>
<div class="col-mode">模式</div>
<div class="col-kit">齐套率</div>
@ -86,18 +103,26 @@
@update:checked="(v: boolean) => toggleSelect(orderRowKey(order), v)"
/>
</div>
<div class="col-main-code" :title="order.mainCode || ''">
{{ order.mainCode || '-' }}
</div>
<div class="col-product">
<div class="product-name">{{ order.materialName || '-' }}</div>
<div class="product-code">
<div class="product-name" :title="order.materialName || ''">
{{ order.materialName || '-' }}
</div>
<div class="product-code" :title="order.materialCode || ''">
{{ order.materialCode || '-' }}
</div>
<div class="product-sub">
<span>{{ formatWorkOrderCodes(order) }}</span>
<n-tag v-if="isOverdue(order)" size="tiny" type="error" :bordered="false">逾期</n-tag>
<n-tag v-if="hasOutsource(order)" size="tiny" type="warning" :bordered="false">委外</n-tag>
<n-tag v-if="isOverdue(order)" size="small" type="error" :bordered="false">逾期</n-tag>
<n-tag v-if="hasOutsource(order)" size="small" type="warning" :bordered="false">委外</n-tag>
</div>
</div>
<div class="col-qty">{{ order.quantity ?? '-' }}</div>
<div class="col-mode">
<n-tag v-if="order.routeCode" size="tiny" :bordered="false">工艺型</n-tag>
<n-tag v-if="order.workshopName" size="tiny" type="info" :bordered="false">{{ order.workshopName }}</n-tag>
<n-tag v-if="order.routeCode" size="small" :bordered="false">工艺型</n-tag>
<n-tag v-if="order.workshopName" size="small" type="info" :bordered="false">{{ order.workshopName }}</n-tag>
</div>
<div class="col-kit">-</div>
<div class="col-flow" :class="{ 'flow-expanded': isExpanded(order) }">
@ -109,29 +134,29 @@
</div>
<div class="col-plan">
<div>{{ formatShortTime(order.beginTime) }}</div>
<div class="sub-text">{{ formatShortTime(order.endTime) }}</div>
<div>{{ formatShortTime(order.endTime) }}</div>
</div>
<div class="col-life">
<div class="life-cell">
<n-icon :color="lifeIcon(order).color" size="18">
<n-icon :color="lifeIcon(order).color" size="20">
<component :is="lifeIcon(order).icon" />
</n-icon>
<span>{{ lifeLabel(order) }}</span>
</div>
</div>
<div class="col-actions">
<n-button text type="primary" size="tiny" @click="openDetail(order)">详情</n-button>
<n-button text type="info" size="tiny" @click="openPlmModel(order)">模型</n-button>
<n-button text type="primary" size="small" @click="openDetail(order)">详情</n-button>
<n-button text type="info" size="small" @click="openPlmModel(order)">模型</n-button>
<!-- 订单级编辑打开工序顺序重排弹窗不进单工序编辑 -->
<n-button
text
type="primary"
size="tiny"
size="small"
:disabled="!hasPermission('biz:orderProcessPlan:edit')"
@click="openReorder(order)"
>编辑</n-button>
<n-dropdown :options="moreOptions" trigger="click" @select="(k) => onMoreSelect(k, order)">
<n-button text size="tiny">更多</n-button>
<n-button text size="small">更多</n-button>
</n-dropdown>
</div>
</div>
@ -378,10 +403,19 @@
<span class="pgClass">已完成</span>
<span>{{dispatchform.completedQty ?? 0}}</span>
</n-gi>
<n-gi>
<span class="pgClass">已派工</span>
<span>{{dispatchform.assignedQty ?? 0}}</span>
</n-gi>
</n-grid>
<n-grid :cols="2">
<n-gi>
<span class="pgClass">本次待派</span>
<span style="color: #2080f0; font-weight: 600">{{dispatchform.remainQty}}</span>
</n-gi>
<n-gi>
<span class="hint-inline" style="color:#999;font-size:12px">待派 = 计划 已派工</span>
</n-gi>
</n-grid>
<n-grid :cols="2">
<n-gi>
@ -498,7 +532,7 @@
label-width="110"
>
<n-form-item label="供应商" path="supplierName">
<n-input v-model:value="outsourceForm.supplierName" placeholder="请输入供应商名称" />
<n-input v-model:value="outsourceForm.supplierName" placeholder="可不填,空则为草稿,补齐后再下推金蝶" />
</n-form-item>
<n-form-item label="委外数量" path="quantity">
<n-input-number
@ -666,6 +700,11 @@
</template>
</n-modal>
<WorkOrderPrintPreview
v-model:show="printVisible"
:order="printData"
:loading="printLoading"
/>
</div>
</template>
@ -675,7 +714,8 @@ import {ref, reactive, onMounted, h, computed, type Component, toRaw} from 'vue'
import {
NButton, NSpace, NIcon, NTag, NSpin, NModal, NForm, NFormItem, NGrid, NGi,
NInput, NInputNumber, NDatePicker, NCheckbox, NEmpty, NPagination, NDropdown, NDescriptions,
NDescriptionsItem, NAlert, NUpload, NText, NCard, NRadioGroup, NRadio, NDivider, useMessage, useDialog,
NDescriptionsItem, NAlert, NUpload, NText, NCard, NRadioGroup, NRadio, NDivider, NSelect,
useMessage, useDialog,
type UploadCustomRequestOptions, type FormRules,
} from 'naive-ui'
import {
@ -697,9 +737,11 @@ import {
type OutsourcingProcessOption, OrderProcessPlan,
} from '@/api/orderProcessPlan'
import { useUserStore } from '@/stores/user'
import { deptApi } from '@/api/org'
import ProcessFlowStepper from './components/ProcessFlowStepper.vue'
import ProcessCard from './components/ProcessCard.vue'
import SplitProcessDialog from './components/SplitProcessDialog.vue'
import WorkOrderPrintPreview from './components/WorkOrderPrintPreview.vue'
import PlmModelDrawer from '@/components/PlmModelDrawer.vue'
import type { PlmModelOpenContext } from '@/api/plmModel'
import Pgitem from './pgitem.vue'
@ -714,10 +756,14 @@ const userStore = useUserStore()
const hasPermission = (p: string) => userStore.hasPermission(p)
const searchForm = reactive({
name: '',
code: '',
mainCode: '',
materialCode: '',
materialName: '',
workshopId: null as number | null,
})
const workshopOptions = ref<{ label: string; value: number }[]>([])
const tableData = ref<OrderProcessPlanVO[]>([])
const expandedIds = ref<Set<string>>(new Set())
const selectedOrderIds = ref<string[]>([])
@ -728,6 +774,10 @@ const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<OrderProcessPlanVO | null>(null)
const printVisible = ref(false)
const printLoading = ref(false)
const printData = ref<OrderProcessPlanVO | null>(null)
/** 工序工单拆单 */
const splitVisible = ref(false)
const splitPlanId = ref<number | null>(null)
@ -758,6 +808,7 @@ const dispatchLoading = ref(false)
const dispatchformRef = ref()
const dispatchform = reactive<any>({
id: '', name: '', beginTime: '', endTime: '', quantity: '', sectionId: null as number | null, workshopId: null as number | null,
completedQty: 0, assignedQty: 0, remainQty: 0,
list: [{ sectionId:'',deviceId:'', quantity: '', ncId:'' }],
})
const dispatchrules = {}
@ -783,7 +834,6 @@ const outsourceForm = reactive({
remark: '',
})
const outsourceRules: FormRules = {
supplierName: [{ required: true, message: '请输入供应商', trigger: 'blur' }],
quantity: [{ required: true, type: 'number', message: '请输入委外数量', trigger: ['blur', 'change'] }],
}
@ -1021,8 +1071,10 @@ async function loadData() {
const res = await orderProcessPlanApi.page({
page: pagination.page,
pageSize: pagination.pageSize,
name: searchForm.name || undefined,
code: searchForm.code || undefined,
mainCode: searchForm.mainCode || undefined,
materialCode: searchForm.materialCode || undefined,
materialName: searchForm.materialName || undefined,
workshopId: searchForm.workshopId ?? undefined,
})
tableData.value = res.list ?? []
pagination.itemCount = res.total
@ -1037,8 +1089,10 @@ function handleSearch() {
}
function handleReset() {
searchForm.name = ''
searchForm.code = ''
searchForm.mainCode = ''
searchForm.materialCode = ''
searchForm.materialName = ''
searchForm.workshopId = null
handleSearch()
}
@ -1062,6 +1116,10 @@ function onBatchSelect(key: string) {
}
function onMoreSelect(key: string, order: OrderProcessPlanVO) {
if (key === 'print') {
openPrintPreview(order)
return
}
if (key === 'createOutsource') {
openOutsource(order)
return
@ -1076,7 +1134,6 @@ function onMoreSelect(key: string, order: OrderProcessPlanVO) {
}
const labels: Record<string, string> = {
print: '打印',
withdraw: '撤回',
freeze: '冻结工单',
delete: '删除',
@ -1171,14 +1228,14 @@ async function submitOutsource() {
try {
await orderProcessPlanApi.createOutsourcing({
processPlanId: outsourceSelectedPlanId.value,
supplierName: outsourceForm.supplierName.trim(),
supplierName: outsourceForm.supplierName.trim() || undefined,
quantity: outsourceForm.quantity!,
unitPrice: outsourceForm.unitPrice ?? undefined,
planStartTime: formatSubmitDateTime(outsourceForm.planStartTime),
planEndTime: formatSubmitDateTime(outsourceForm.planEndTime),
remark: outsourceForm.remark || undefined,
})
message.success('委外单创建成功')
message.success(outsourceForm.supplierName.trim() ? '委外单创建成功' : '已创建草稿委外单,补齐供应商后可下推金蝶')
outsourceModalVisible.value = false
loadData()
} finally {
@ -1186,6 +1243,24 @@ async function submitOutsource() {
}
}
async function openPrintPreview(order: OrderProcessPlanVO) {
if (!order.workOrderCode) {
message.warning('当前记录缺少工单号,二维码将留空')
}
printVisible.value = true
printLoading.value = true
printData.value = order
try {
if (order.orderItemId) {
printData.value = await orderProcessPlanApi.detail(order.orderItemId, order.workOrderCode)
}
} catch {
printData.value = order
} finally {
printLoading.value = false
}
}
async function openDetail(order: OrderProcessPlanVO) {
if (!order.orderItemId) return
detailVisible.value = true
@ -1326,9 +1401,13 @@ function disphand(process: ProcessPlanItemVO, order?: OrderProcessPlanVO) {
const planQty = Number(entity.quantity) || 0
const completedQty = Number(process.completedQty) || 0
const patchNum = Number(process.patchNum) || 0
const refillNum = Number(process.refillNum) || 0 //
const remainQty = Math.max(0, (planQty+refillNum) - patchNum)
// checkOverProcessNum = +
const assignedQty = (process.assignWorkList ?? []).reduce(
(s, a) => s + (Number(a.quantity) || 0),
0,
)
const refillNum = Number(process.refillNum) || 0
const remainQty = Math.max(0, planQty + refillNum - assignedQty)
dispatchmodal.value = true
dispatchformRef.value?.restoreValidation()
dispatchform.id = entity.id
@ -1339,12 +1418,13 @@ function disphand(process: ProcessPlanItemVO, order?: OrderProcessPlanVO) {
// quantity
dispatchform.quantity = entity.quantity
dispatchform.completedQty = completedQty
dispatchform.assignedQty = assignedQty
dispatchform.remainQty = remainQty
dispatchform.orderItemId = entity.orderItemId
dispatchform.sort = entity.sort
dispatchform.sectionId = entity.sectionId ?? process.sectionId ?? null
dispatchform.workshopId = entity.workshopId ?? process.workshopId ?? order?.workshopId ?? null
dispatchform.list = [{ sectionId: '', deviceId: '', quantity: remainQty+'' || '',ncId:'' }]
dispatchform.list = [{ sectionId: '', deviceId: '', quantity: remainQty > 0 ? String(remainQty) : '', ncId: '' }]
}
function addpgnum() {
@ -1362,17 +1442,19 @@ function dispatchSubmit() {
})
const remainQty = Number(dispatchform.remainQty)
if (remainQty <= 0) {
message.warning('当前工序已全部完成,无需再派工')
return
//
message.warning('当前工序计划数量已派满,若因补料继续派工请确认数量')
}
dispatchformRef.value?.validate((errors: any) => {
// Naive UI errors
if (errors) return
//
// if (total !== remainQty) {
// message.error(` ${remainQty} ${dispatchform.quantity} ${dispatchform.completedQty ?? 0}`, {
// duration: 4000,
// })
// message.error(
// ` ${remainQty} ${dispatchform.quantity} ${dispatchform.assignedQty ?? 0} ${dispatchform.completedQty ?? 0}`,
// { duration: 4000 },
// )
// return
// }
@ -1387,10 +1469,7 @@ function dispatchSubmit() {
}
async function handleExport() {
const blob = await orderProcessPlanApi.export({
name: searchForm.name || undefined,
code: searchForm.code || undefined,
})
const blob = await orderProcessPlanApi.export({})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
@ -1434,12 +1513,26 @@ function openReportDetail(order) {
}
onMounted(loadData)
async function loadWorkshopOptions() {
try {
const data = await deptApi.list()
workshopOptions.value = (data || [])
.filter((d: any) => d.deptName && String(d.deptName).includes('车间'))
.map((d: any) => ({ label: d.deptName, value: Number(d.id) }))
} catch {
workshopOptions.value = []
}
}
onMounted(() => {
loadWorkshopOptions()
loadData()
})
</script>
<style scoped>
.plan-board-page {
padding: 0;
padding: 20px;
background: #f5f7fa;
min-height: 100%;
}
@ -1450,18 +1543,23 @@ onMounted(loadData)
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
padding: 16px 20px;
background: #fff;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #eef0f3;
}
.board-filter {
padding: 12px 16px;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #eef0f3;
}
.board-filter :deep(.n-form-item) {
margin-right: 8px;
margin-bottom: 0;
}
.board-table-wrap {
overflow-x: auto;
background: #fff;
@ -1470,28 +1568,29 @@ onMounted(loadData)
.board-table-head,
.order-row {
display: grid;
/* 勾选 | 产品工单 | 数量 | 模式 | 齐套率 | 工序轴 | 计划时间 | 生命周期 | 操作 */
/* 勾选 | 生产令号 | 物料名称/编码 | 数量 | 模式 | 齐套率 | 工序轴 | 计划时间 | 生命周期 | 操作 */
grid-template-columns:
40px
minmax(220px, 1.6fr)
64px
108px
48px
minmax(150px, 1fr)
minmax(240px, 1.6fr)
72px
minmax(200px, 1.4fr)
minmax(150px, 1fr)
72px
minmax(240px, 1.5fr)
168px
96px
140px;
110px
168px;
align-items: center;
gap: 8px;
padding: 0 16px;
min-width: 1280px;
gap: 10px;
padding: 0 20px;
min-width: 1560px;
}
.board-table-head {
height: 40px;
height: 46px;
background: #fafbfc;
border-bottom: 1px solid #e8eaed;
font-size: 12px;
font-size: 13px;
font-weight: 600;
color: #8c8c8c;
}
@ -1502,7 +1601,7 @@ onMounted(loadData)
}
.order-row {
min-height: 64px;
min-height: 84px;
transition: background 0.15s;
}
@ -1527,12 +1626,23 @@ onMounted(loadData)
.col-qty,
.col-kit {
text-align: center;
font-size: 13px;
font-size: 14px;
white-space: nowrap;
}
.product-name {
font-size: 14px;
.col-main-code {
text-align: left;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-name,
.product-code {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
overflow: hidden;
@ -1541,31 +1651,40 @@ onMounted(loadData)
}
.product-code {
margin-top: 2px;
}
.product-sub {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
gap: 8px;
margin-top: 4px;
font-size: 12px;
font-size: 13px;
color: #8c8c8c;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-sub :deep(.n-tag) {
flex-shrink: 0;
}
.col-mode {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.col-plan {
font-size: 13px;
line-height: 1.4;
font-size: 14px;
line-height: 1.5;
white-space: nowrap;
}
.col-plan .sub-text {
font-size: 12px;
color: #8c8c8c;
margin-top: 2px;
color: #1a1a1a;
}
.col-life {
@ -1585,19 +1704,19 @@ onMounted(loadData)
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-size: 14px;
}
.col-actions {
display: flex;
align-items: center;
gap: 4px;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.order-expand {
padding: 12px 16px 16px 56px;
padding: 16px 20px 20px 68px;
background: linear-gradient(180deg, rgba(32, 128, 240, 0.03) 0%, #fff 100%);
border-top: 1px dashed #e8eaed;
}
@ -1618,20 +1737,20 @@ onMounted(loadData)
}
.expand-empty {
font-size: 13px;
font-size: 14px;
color: #8c8c8c;
padding: 8px 0;
padding: 12px 0;
}
.board-empty {
padding: 48px;
padding: 56px;
background: #fff;
}
.board-pagination {
display: flex;
justify-content: flex-end;
padding: 12px 16px;
padding: 16px 20px;
background: #fff;
border-radius: 0 0 8px 8px;
}

View File

@ -19,7 +19,7 @@
:stroke-width="10"
:color="progressColor"
:rail-color="railColor"
style="width: 88px"
style="width: 96px"
>
<span class="progress-text">{{ progressPct }}%</span>
</n-progress>
@ -74,47 +74,47 @@
<div class="card-actions">
<n-button
v-if="canAssign"
size="tiny"
size="small"
type="warning"
ghost
@click="emit('assign', process)"
>派工</n-button>
<n-button
v-if="canSplit"
size="tiny"
size="small"
type="primary"
ghost
@click="emit('split', process)"
>拆单</n-button>
<n-button
v-if="canEdit"
size="tiny"
size="small"
ghost
@click="emit('edit', process)"
>编辑</n-button>
<n-button size="tiny" type="info" ghost @click="emit('viewModel', process)">模型</n-button>
<n-button size="small" type="info" ghost @click="emit('viewModel', process)">模型</n-button>
<n-button
v-if="canOutsource"
size="tiny"
size="small"
type="warning"
ghost
@click="emit('outsource', process)"
>委外</n-button>
<n-button
size="tiny"
size="small"
type="info"
ghost
@click="emit('submitDetail', process)"
>汇报</n-button>
<n-button
:disabled="refillDisAbled"
size="tiny"
size="small"
type="info"
ghost
@click="emit('handleLeftoverMaterial', process)"
>补料</n-button>
<n-button
size="tiny"
size="small"
:type="process.processStatus === 4 ? 'success' : 'default'"
:ghost="process.processStatus !== 4"
disabled
@ -122,7 +122,7 @@
</div>
</div>
<div v-if="!isLast" class="card-chevron">
<n-icon size="28" color="#c0c4cc"><ChevronForwardOutline /></n-icon>
<n-icon size="30" color="#c0c4cc"><ChevronForwardOutline /></n-icon>
</div>
</div>
</template>
@ -275,7 +275,7 @@ onMounted(()=>{
}
.process-card {
width: 220px;
width: 248px;
border-radius: 8px;
border: 1px solid #e8e8e8;
background: #fff;
@ -323,12 +323,12 @@ onMounted(()=>{
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
min-height: 36px;
padding: 10px 12px;
min-height: 40px;
}
.card-title {
font-size: 13px;
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
@ -350,7 +350,7 @@ onMounted(()=>{
}
.card-material {
font-size: 11px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
overflow: hidden;
@ -361,17 +361,17 @@ onMounted(()=>{
.card-progress {
display: flex;
justify-content: center;
padding: 12px 0 8px;
padding: 14px 0 10px;
}
.progress-text {
font-size: 14px;
font-size: 15px;
font-weight: 700;
color: #333;
}
.card-stats {
padding: 0 12px 8px;
padding: 0 12px 10px;
display: flex;
flex-direction: column;
gap: 4px;
@ -380,7 +380,7 @@ onMounted(()=>{
.stat-row {
display: flex;
justify-content: space-between;
font-size: 12px;
font-size: 13px;
}
.stat-label {

View File

@ -11,7 +11,7 @@
:title="step.label"
>
<span class="flow-dot">
<n-icon v-if="step.state === 'done'" size="12"><CheckmarkOutline /></n-icon>
<n-icon v-if="step.state === 'done'" size="14"><CheckmarkOutline /></n-icon>
<span v-else class="flow-num">{{ idx + 1 }}</span>
</span>
<span class="flow-label">{{ step.shortLabel }}</span>
@ -51,7 +51,7 @@ const steps = computed(() => {
return {
key: item.planId ?? `${item.processCode}-${idx}`,
label,
shortLabel: label.length > 5 ? `${label.slice(0, 5)}` : label,
shortLabel: label.length > 6 ? `${label.slice(0, 6)}` : label,
state,
}
})
@ -73,9 +73,9 @@ function onClick() {
.flow-stepper.clickable {
cursor: pointer;
border-radius: 6px;
padding: 4px 6px;
margin: -4px -6px;
border-radius: 8px;
padding: 6px 8px;
margin: -6px -8px;
transition: background 0.15s;
}
@ -87,19 +87,19 @@ function onClick() {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 5px;
flex-shrink: 0;
max-width: 56px;
max-width: 68px;
}
.flow-dot {
width: 22px;
height: 22px;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-size: 12px;
font-weight: 600;
border: 2px solid #d9d9d9;
background: #fff;
@ -120,7 +120,7 @@ function onClick() {
}
.flow-label {
font-size: 11px;
font-size: 12px;
color: #8c8c8c;
text-align: center;
line-height: 1.2;
@ -134,12 +134,12 @@ function onClick() {
.flow-line {
flex: 1;
min-width: 12px;
max-width: 28px;
min-width: 14px;
max-width: 32px;
height: 2px;
background: #e8e8e8;
margin: 0 2px;
margin-bottom: 16px;
margin: 0 3px;
margin-bottom: 18px;
}
.flow-line.done,

View File

@ -183,7 +183,8 @@
</n-spin>
<template #footer>
<n-space justify="end">
<n-space justify="end" align="center">
<span v-if="!canConfirm && confirmDisabledReason" class="confirm-hint">{{ confirmDisabledReason }}</span>
<n-button @click="visible = false">取消</n-button>
<n-button type="primary" :loading="submitting" :disabled="!canConfirm" @click="confirmSplit">
确认拆分
@ -301,36 +302,55 @@ function onKeepQtyUpdate(v: number | null) {
/**
* 等量拆分把整单计划数量均分成 N 含原单
* quantities = [原单保留, 新单1, 新单2, ...]
* 若均分后第1份 < 不可拆数量则从后面的新单挪量补足第1份每份新单至少留 1
*/
const equalQuantities = computed(() => {
const q = planQty.value
const n = equalCount.value
if (!q || !n || n < 2 || q < n) return [] as number[]
const q = Number(planQty.value) || 0
const n = Number(equalCount.value) || 0
const inv = Number(unsplittableQty.value) || 0
if (q < 2 || n < 2 || q < n) return [] as number[]
const base = Math.floor(q / n)
const rem = q % n
const list = Array.from({ length: n }, () => base)
// 0
list[n - 1] = base + rem
if (list.some((v) => v <= 0)) return []
// 1
if (list[0] < unsplittableQty.value) return []
if (list[0] < inv) {
let need = inv - list[0]
list[0] = inv
for (let i = n - 1; i >= 1 && need > 0; i--) {
const take = Math.min(Math.max(list[i] - 1, 0), need)
list[i] -= take
need -= take
}
if (need > 0) return [] as number[]
}
if (list.some((v) => v <= 0)) return [] as number[]
if (list.reduce((s, v) => s + v, 0) !== q) return [] as number[]
return list
})
/** 在「第1份 ≥ 不可拆」前提下,最多能拆成几份 */
/** 在「第1份可覆盖不可拆、且每份新单≥1」前提下,最多能拆成几份 */
const maxEqualParts = computed(() => {
const q = planQty.value
const inv = unsplittableQty.value
const q = Number(planQty.value) || 0
const inv = Number(unsplittableQty.value) || 0
if (q < 2) return 2
let max = Math.min(q, Math.max(2, q - inv)) // 1
// floor(q/n) >= inv=floor
while (max > 2 && Math.floor(q / max) < inv) {
max -= 1
}
// 1 < q
// q - max(inv, 1) 1
let max = Math.min(q, Math.max(2, q - Math.max(inv, 0)))
while (max > 2) {
const base = Math.floor(q / max)
if (base >= Math.max(1, inv) && base < q) break
const rem = q % max
const first = base
const last = base + rem
// first < inv (max-1) (qty-1)
let need = Math.max(0, inv - first)
let canGive = 0
for (let i = 1; i < max; i++) {
const qty = i === max - 1 ? last : base
canGive += Math.max(qty - 1, 0)
}
if (need <= canGive && base + (need > 0 ? need : 0) < q) break
max -= 1
}
return Math.max(2, max)
@ -340,8 +360,8 @@ const equalHint = computed(() => {
const list = equalQuantities.value
if (!list.length) {
if (planQty.value < equalCount.value) return `计划数量不足以分成 ${equalCount.value}`
if (equalCount.value >= 2 && Math.floor(planQty.value / equalCount.value) < unsplittableQty.value) {
return `第1份无法覆盖不可拆数量 ${unsplittableQty.value},请减少份数`
if (equalCount.value >= 2) {
return `无法覆盖不可拆数量 ${unsplittableQty.value} 的前提下分成 ${equalCount.value},请减少份数或改用「指定数量」`
}
return ''
}
@ -599,7 +619,6 @@ const canConfirm = computed(() => {
if (!preview.value?.canSplit || submitting.value) return false
if (mode.value === 'equal') {
//
if (!equalQuantities.value.length) return false
} else {
if (keepQty.value == null || keepQty.value < unsplittableQty.value) return false
@ -613,6 +632,30 @@ const canConfirm = computed(() => {
return assembleValid.value
})
/** 按钮禁用原因,便于排查「点不了」 */
const confirmDisabledReason = computed(() => {
if (!preview.value) return '正在加载预览'
if (!preview.value.canSplit) return preview.value.reason || '当前工单不允许拆单'
if (submitting.value) return ''
if (mode.value === 'equal' && !equalQuantities.value.length) {
return '等量拆分数量无效,请调整份数或改用指定数量'
}
if (mode.value === 'custom') {
if (keepQty.value == null || keepQty.value < unsplittableQty.value) return '原单保留数量不能小于不可拆数量'
if (splitOutQty.value <= 0) return '没有可拆出的数量'
if (customRows.value.some((v) => v == null || v <= 0)) return '请填写每张新工单数量'
if (customSum.value !== splitOutQty.value) return `拆出合计须等于 ${splitOutQty.value}`
}
if (routeMode.value === 'kingdee') {
if (!preview.value.materialCode) return '无物料编码,请改用「创建新工艺路线」'
if (!routeList.value.length) return '未查到金蝶工艺路线,请改用「创建新工艺路线」'
if (!routingNo.value) return '请先选择一条金蝶工艺路线'
} else if (!assembleValid.value) {
return '请为每道工序选择或填写工序信息'
}
return ''
})
watch(
() => [props.show, props.planId] as const,
async ([show, planId]) => {
@ -661,6 +704,10 @@ async function loadRouteOptions(materialCode?: string | null) {
if (!materialCode) return
const list = (await processRouteApi.kingdeeSelect(materialCode)) as ProcessRouteSelectOption[]
routeList.value = (Array.isArray(list) ? list : []).filter((r) => !!r?.routingNo)
//
if (!routingNo.value && routeList.value.length) {
routingNo.value = routeList.value[0].routingNo || null
}
} catch (e: any) {
routeList.value = []
message.error(e?.message || '加载金蝶工艺路线失败')
@ -918,6 +965,13 @@ async function confirmSplit() {
.sum-line.error {
color: #d03050;
}
.confirm-hint {
font-size: 12px;
color: #d03050;
margin-right: 8px;
max-width: 420px;
line-height: 1.4;
}
@media (max-width: 1100px) {
.kingdee-layout {
grid-template-columns: 1fr;

View File

@ -0,0 +1,314 @@
<template>
<n-modal
:show="show"
preset="card"
title="工单打印预览"
:mask-closable="false"
:style="{ width: 'min(980px, 96vw)' }"
@update:show="emit('update:show', $event)"
>
<n-spin :show="loading">
<div class="wo-preview-stage">
<div v-if="!order && !loading" class="wo-preview-empty">暂无工单数据</div>
<div v-else ref="sheetsRef" class="wo-preview-pages">
<section
v-for="page in pages"
:key="page.index"
class="wo-a4-sheet"
>
<div class="wo-a4-frame">
<header class="wo-a4-head" :class="{ 'is-cont': page.index > 1 }">
<div>
<h1 class="wo-a4-title">
{{ page.index === 1 ? '工序计划工单' : '工序计划工单(续)' }}
</h1>
<div class="wo-a4-id">工单号 <b>{{ workOrderCode || '未赋码' }}</b></div>
</div>
<div class="wo-a4-qr">
<div class="wo-a4-qr-box">
<img v-if="qrDataUrl" :src="qrDataUrl" alt="工单号二维码" />
<div v-else class="wo-a4-qr-empty">无码</div>
</div>
<div class="wo-a4-qr-cap">扫码</div>
</div>
</header>
<dl v-if="page.index === 1" class="wo-a4-meta">
<div class="wo-a4-meta-item">
<dt>生产令号</dt>
<dd>{{ text(order?.mainCode) }}</dd>
</div>
<div class="wo-a4-meta-item">
<dt>生产订单</dt>
<dd>{{ text(order?.orderCode) }}</dd>
</div>
<div class="wo-a4-meta-item">
<dt>计划数量</dt>
<dd>{{ order?.quantity ?? '-' }}</dd>
</div>
<div class="wo-a4-meta-item">
<dt>生产车间</dt>
<dd>{{ text(order?.workshopName) }}</dd>
</div>
<div class="wo-a4-meta-item is-wide">
<dt>物料</dt>
<dd>{{ materialText }}</dd>
</div>
<div class="wo-a4-meta-item">
<dt>计划开工</dt>
<dd>{{ formatTime(order?.beginTime) }}</dd>
</div>
<div class="wo-a4-meta-item">
<dt>计划完工</dt>
<dd>{{ formatTime(order?.endTime) }}</dd>
</div>
</dl>
<div class="wo-a4-table-wrap">
<table class="wo-a4-table">
<colgroup>
<col class="col-no" />
<col class="col-name" />
<col class="col-time" />
<col class="col-time" />
<col class="col-qty" />
</colgroup>
<thead>
<tr>
<th>工序号</th>
<th>工序名称</th>
<th>计划开始</th>
<th>计划结束</th>
<th>数量</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in page.rows"
:key="row.planId ?? `${page.index}-${idx}`"
>
<td>{{ row.operNumber ?? '-' }}</td>
<td class="is-left">{{ text(row.processName) }}</td>
<td>{{ formatTime(row.planStartTime || row.beginTime) }}</td>
<td>{{ formatTime(row.planFinishTime || row.endTime) }}</td>
<td>{{ row.quantity ?? '-' }}</td>
</tr>
</tbody>
</table>
<div v-if="!page.rows.length" class="wo-a4-empty">本工单暂无工序计划</div>
</div>
<footer v-if="page.index === pages.length" class="wo-a4-foot">
<div class="wo-a4-sign">
<div class="wo-a4-sign-cell">
<b>操作工</b>
<div class="wo-a4-sign-line" />
</div>
<div class="wo-a4-sign-cell">
<b>质检</b>
<div class="wo-a4-sign-line" />
</div>
<div class="wo-a4-sign-cell">
<b>班组确认</b>
<div class="wo-a4-sign-line" />
</div>
</div>
<div class="wo-a4-note">
<span>打印人 {{ printerName }} {{ printedAt }}</span>
<span>{{ page.index }} / {{ page.total }}</span>
</div>
</footer>
<div v-else class="wo-a4-note">
<span>续页 {{ workOrderCode || '-' }}</span>
<span>{{ page.index }} / {{ page.total }}</span>
</div>
</div>
</section>
</div>
</div>
</n-spin>
<template #footer>
<n-space justify="end">
<n-button @click="emit('update:show', false)">关闭</n-button>
<n-button type="primary" :disabled="!order || loading" @click="handlePrint">
打印
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NModal, NSpace, NSpin, useMessage } from 'naive-ui'
import QRCode from 'qrcode'
import printCss from './workOrderPrint.css?inline'
import './workOrderPrint.css'
import {
type OrderProcessPlanVO,
type ProcessPlanItemVO,
} from '@/api/orderProcessPlan'
import { useUserStore } from '@/stores/user'
const FIRST_PAGE_ROWS = 16
const NEXT_PAGE_ROWS = 20
const props = defineProps<{
show: boolean
order: OrderProcessPlanVO | null
loading?: boolean
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
}>()
const message = useMessage()
const userStore = useUserStore()
const sheetsRef = ref<HTMLElement | null>(null)
const qrDataUrl = ref('')
const printedAt = ref('')
const workOrderCode = computed(() => props.order?.workOrderCode?.trim() || '')
const printerName = computed(() => userStore.nickname || '-')
const materialText = computed(() => {
const name = props.order?.materialName || '-'
const code = props.order?.materialCode
return code ? `${name} ${code}` : name
})
const sortedProcesses = computed(() => {
return [...(props.order?.processList ?? [])].sort((a, b) => {
const oa = a.operNumber ?? 0
const ob = b.operNumber ?? 0
if (oa !== ob) return oa - ob
return (a.planId ?? 0) - (b.planId ?? 0)
})
})
const pages = computed(() => {
const list = sortedProcesses.value
if (!list.length) {
return [{ index: 1, total: 1, rows: [] as ProcessPlanItemVO[] }]
}
const chunks: ProcessPlanItemVO[][] = [list.slice(0, FIRST_PAGE_ROWS)]
for (let i = FIRST_PAGE_ROWS; i < list.length; i += NEXT_PAGE_ROWS) {
chunks.push(list.slice(i, i + NEXT_PAGE_ROWS))
}
return chunks.map((rows, i) => ({
index: i + 1,
total: chunks.length,
rows,
}))
})
function text(v?: string | null) {
return v && String(v).trim() ? String(v) : '-'
}
function formatTime(val?: string | null) {
if (!val) return '-'
return val.length > 16 ? val.slice(0, 16) : val
}
function nowText() {
const d = new Date()
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
watch(
() => [props.show, workOrderCode.value] as const,
async ([visible, code]) => {
if (!visible) return
printedAt.value = nowText()
if (!code) {
qrDataUrl.value = ''
return
}
try {
qrDataUrl.value = await QRCode.toDataURL(code, {
errorCorrectionLevel: 'M',
margin: 1,
width: 320,
color: { dark: '#000000', light: '#ffffff' },
})
} catch {
qrDataUrl.value = ''
}
},
{ immediate: true },
)
function handlePrint() {
const html = sheetsRef.value?.innerHTML
if (!html) {
message.warning('没有可打印的内容')
return
}
const iframe = document.createElement('iframe')
iframe.setAttribute('aria-hidden', 'true')
iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'
document.body.appendChild(iframe)
const doc = iframe.contentDocument
if (!doc) {
iframe.remove()
message.error('无法打开打印预览')
return
}
const title = workOrderCode.value || '工序计划工单'
doc.open()
doc.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title><style>${printCss}html,body{margin:0;background:#fff}</style></head><body>${html}</body></html>`)
doc.close()
const run = () => {
iframe.contentWindow?.focus()
iframe.contentWindow?.print()
window.setTimeout(() => iframe.remove(), 800)
}
const imgs = Array.from(doc.images)
if (!imgs.length) {
run()
return
}
Promise.all(
imgs.map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete) resolve()
else {
img.onload = () => resolve()
img.onerror = () => resolve()
}
}),
),
).then(run)
}
</script>
<style scoped>
.wo-preview-stage {
max-height: 72vh;
overflow: auto;
padding: 12px;
background: #e8e8e8;
}
.wo-preview-empty {
padding: 48px 0;
text-align: center;
color: #666;
}
.wo-preview-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.wo-preview-pages :deep(.wo-a4-sheet) {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
</style>

View File

@ -0,0 +1,236 @@
/* A4 工单:黑白、简洁,供预览与打印共用 */
.wo-a4-sheet {
box-sizing: border-box;
width: 210mm;
height: 297mm;
padding: 12mm 14mm 12mm;
background: #fff;
color: #000;
page-break-after: always;
break-after: page;
overflow: hidden;
}
.wo-a4-sheet:last-child {
page-break-after: auto;
break-after: auto;
}
.wo-a4-frame {
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
border: 1px solid #000;
padding: 5mm 5mm 4mm;
}
.wo-a4-head {
display: grid;
grid-template-columns: 1fr 32mm;
gap: 6mm;
align-items: start;
padding-bottom: 4mm;
border-bottom: 1px solid #000;
}
.wo-a4-head.is-cont {
grid-template-columns: 1fr 18mm;
padding-bottom: 3mm;
}
.wo-a4-title {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.2em;
line-height: 1.3;
}
.wo-a4-id {
margin-top: 3mm;
font-size: 13px;
}
.wo-a4-id b {
font-weight: 700;
}
.wo-a4-qr {
width: 32mm;
text-align: center;
}
.wo-a4-head.is-cont .wo-a4-qr {
width: 18mm;
}
.wo-a4-qr-box {
box-sizing: border-box;
width: 32mm;
height: 32mm;
padding: 1mm;
border: 1px solid #000;
background: #fff;
}
.wo-a4-head.is-cont .wo-a4-qr-box {
width: 18mm;
height: 18mm;
padding: 0.5mm;
}
.wo-a4-qr-box img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.wo-a4-qr-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
}
.wo-a4-qr-cap {
margin-top: 1mm;
font-size: 9px;
}
.wo-a4-meta {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
border: 1px solid #000;
border-top: 0;
font-size: 12px;
}
.wo-a4-meta-item {
padding: 2.2mm 3mm;
border-right: 1px solid #000;
border-bottom: 1px solid #000;
min-width: 0;
}
.wo-a4-meta-item:nth-child(4n),
.wo-a4-meta-item:last-child {
border-right: 0;
}
.wo-a4-meta-item:nth-child(n + 5) {
border-bottom: 0;
}
.wo-a4-meta-item.is-wide {
grid-column: span 2;
}
.wo-a4-meta-item dt {
margin: 0;
font-size: 10px;
}
.wo-a4-meta-item dd {
margin: 1mm 0 0;
font-weight: 600;
word-break: break-all;
}
.wo-a4-table-wrap {
flex: 1;
min-height: 0;
margin-top: 4mm;
}
.wo-a4-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 11px;
}
.wo-a4-table th,
.wo-a4-table td {
border: 1px solid #000;
padding: 1.8mm 1.6mm;
text-align: center;
vertical-align: middle;
word-break: break-all;
background: #fff;
}
.wo-a4-table th {
font-weight: 700;
}
.wo-a4-table td.is-left {
text-align: left;
}
.wo-a4-table .col-no { width: 16mm; }
.wo-a4-table .col-name { width: auto; }
.wo-a4-table .col-time { width: 36mm; }
.wo-a4-table .col-qty { width: 18mm; }
.wo-a4-empty {
padding: 10mm 0;
text-align: center;
font-size: 13px;
border: 1px solid #000;
border-top: 0;
}
.wo-a4-foot {
margin-top: 5mm;
}
.wo-a4-sign {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border: 1px solid #000;
}
.wo-a4-sign-cell {
min-height: 16mm;
padding: 2mm 3mm 2.5mm;
border-right: 1px solid #000;
}
.wo-a4-sign-cell:last-child {
border-right: 0;
}
.wo-a4-sign-cell b {
display: block;
font-size: 12px;
}
.wo-a4-sign-line {
margin-top: 8mm;
border-bottom: 1px solid #000;
}
.wo-a4-note {
display: flex;
justify-content: space-between;
margin-top: 3mm;
font-size: 10px;
}
@media print {
@page {
size: A4 portrait;
margin: 0;
}
html,
body {
margin: 0;
background: #fff;
}
}

View File

@ -245,6 +245,11 @@
<n-input :value="String(dispatchform.completedQty ?? 0)" disabled />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="已派工">
<n-input :value="String(dispatchform.assignedQty ?? 0)" disabled />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="本次待派">
<n-input :value="String(dispatchform.remainQty ?? 0)" disabled />
@ -867,6 +872,7 @@ let dispatchform = reactive<any>({
userid:'',
quantity:'',
completedQty: 0,
assignedQty: 0,
remainQty: 0,
list:[{
userId:'',
@ -920,7 +926,12 @@ function disphand(process: ProcessPlanItemVO, order?: OrderProcessPlanVO) {
const entity = processItemToEntity(process, order?.orderItemId)
const planQty = Number(entity.quantity) || 0
const completedQty = Number(process.completedQty) || 0
const remainQty = Math.max(0, planQty - completedQty)
// checkOverProcessNum =
const assignedQty = (process.assignWorkList ?? []).reduce(
(s, a) => s + (Number(a.quantity) || 0),
0,
)
const remainQty = Math.max(0, planQty - assignedQty)
dispatchmodal.value = true
dispatchformRef.value?.restoreValidation()
dispatchform.id = entity.id
@ -929,11 +940,12 @@ function disphand(process: ProcessPlanItemVO, order?: OrderProcessPlanVO) {
dispatchform.endTime = entity.endTime || order?.endTime || ''
dispatchform.quantity = entity.quantity
dispatchform.completedQty = completedQty
dispatchform.assignedQty = assignedQty
dispatchform.remainQty = remainQty
dispatchform.userid = ''
dispatchform.list = [{
userId: '',
quantity: remainQty || '',
quantity: remainQty > 0 ? String(remainQty) : '',
}]
}
@ -944,13 +956,13 @@ function dispatchSubmit() {
})
const remainQty = Number(dispatchform.remainQty)
if (remainQty <= 0) {
message.warning('当前工序已全部完成,无需再派工')
message.warning('当前工序已无待派数量(已派工已达计划数量)')
return
}
dispatchformRef.value?.validate((v:any) => {
if(!v){
if(total != remainQty){
message.error(`派工总数量需等于待派数量 ${remainQty}(计划 ${dispatchform.quantity},已完成 ${dispatchform.completedQty ?? 0}`,{
message.error(`派工总数量需等于待派数量 ${remainQty}(计划 ${dispatchform.quantity},已${dispatchform.assignedQty ?? 0},已完成 ${dispatchform.completedQty ?? 0}`,{
duration:4000
})
}else{

View File

@ -37,7 +37,7 @@
clearable
remote
:clear-filter-after-select="false"
@change="loadNc"
@update:value="loadNc"
/>
</n-form-item>
</n-gi>
@ -48,12 +48,20 @@
label="数量"
:path="`${listname}[${index}].quantity`"
:rule="{
required: true,
message: `请输入数量`,
trigger: 'blur',
trigger: ['input', 'blur'],
validator: (_rule: any, value: any) => {
if (value === null || value === undefined || value === '') {
return new Error('请输入数量')
}
const num = Number(value)
if (Number.isNaN(num) || num <= 0) {
return new Error('请输入有效数量')
}
return true
},
}"
>
<n-input v-model:value="obj.quantity"/>
<n-input :value="quantityText" @update:value="onQuantityUpdate" placeholder="请输入数量"/>
</n-form-item>
</n-gi>
<n-gi :span="10">
@ -97,7 +105,7 @@
</template>
<script setup lang="ts">
import {ref, reactive} from 'vue'
import {ref, reactive, computed, watch} from 'vue'
import {userApi} from '@/api/system'
import {sectionApi, type Section} from '@/api/section'
import {
@ -121,10 +129,26 @@ const emit = defineEmits<{
update: [index: any, obj: any]
}>()
/** n-input 需要 string带出的待派数量可能是 number统一转成字符串可直接通过校验 */
const quantityText = computed(() => {
const val = props.obj?.quantity
if (val === null || val === undefined || val === '') return ''
return String(val)
})
// function updatehand() {
// emit('update')
// }
function onQuantityUpdate(val: string) {
props.obj.quantity = val
}
watch(
() => props.obj?.quantity,
(val) => {
if (typeof val === 'number') {
props.obj.quantity = String(val)
}
},
{ immediate: true }
)
function addpgnum() {
emit('addhand')
@ -146,10 +170,6 @@ let ncList = ref<any>([])
function slehand(v?: any) {
loadingRef.value = true
yglist.value.splice(0)
// dispatchform.userlist = [{
// id:'',
// num:''
// }]
userApi.page({
page: 1,
pageSize: 100,
@ -163,7 +183,6 @@ function slehand(v?: any) {
}
})
}
console.log(yglist.value, '12')
loadingRef.value = false
}).catch(() => {
loadingRef.value = false
@ -192,16 +211,19 @@ async function loadSection() {
})
}
//
async function loadNc(row) {
const res = await loadAllNcCode(row)
ncList.value = res.map((n: any) => {
/** 按工位/设备加载已绑定 NC/图纸;无设备时清空 */
async function loadNc(deviceId?: string | number | null) {
props.obj.ncId = null
ncList.value = []
if (deviceId == null || deviceId === '') return
const res = await loadAllNcCode(deviceId)
const list = Array.isArray(res) ? res : (res as any)?.records ?? (res as any)?.list ?? []
ncList.value = list.map((n: any) => {
return {
label: n.name,
label: n.name || n.ncName || n.ncCode || String(n.id),
value: n.id
}
})
console.log(ncList.value)
}

View File

@ -16,6 +16,8 @@
<span class="meta-divider">|</span>
<span>工序 {{ scheduleStats.routeCount }} </span>
<span class="meta-divider">|</span>
<span>已分配设备 {{ scheduleStats.assignedCount }}</span>
<span class="meta-divider">|</span>
<span>今日 {{ todayLabel }}</span>
<span class="meta-divider">|</span>
<span class="meta-legend"><i class="legend-today-line" />今日时间轴</span>
@ -24,10 +26,29 @@
<div class="gantt-panel">
<div class="gantt-panel-head">
<span class="panel-title">排产明细</span>
<span class="panel-tip">主产品行展示物料编码与名称子行展示工序号与工序名称</span>
<span class="panel-tip">点击工序条块打开右侧排产抽屉选择可执行设备/工位</span>
</div>
<div class="gantt-container" ref="ganttContainer" />
</div>
<div class="gantt-legend">
<span class="legend-item"><i class="lg unassigned" />未分配</span>
<span class="legend-item"><i class="lg pending" />待排产</span>
<span class="legend-item"><i class="lg assigned" />已排产</span>
<span class="legend-item"><i class="lg producing" />生产中</span>
<span class="legend-item"><i class="lg paused" />暂停</span>
<span class="legend-item"><i class="lg done" />已完成</span>
</div>
<ProcessScheduleDrawer
v-model:show="drawerShow"
:process="selectedProcess"
:order="selectedOrder"
:mo-index="selectedMoIndex"
:route-index="selectedRouteIndex"
:draft-assigned="draftAssigned"
@save="handleScheduleSave"
/>
</div>
</template>
@ -37,16 +58,23 @@ import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { gantt } from 'dhtmlx-gantt'
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css'
import { orderProjectApi, normalizeKingdeeProcessRoute, resolveProcessName, type KingdeePrdMo } from '@/api/orderProject'
import { orderProjectApi, normalizeKingdeeProcessRoute, resolveProcessName, type KingdeePrdMo, type KingdeeProcessRoute } from '@/api/orderProject'
import ProcessScheduleDrawer, { type DraftAssignedItem, type ScheduleSavePayload } from './ProcessScheduleDrawer.vue'
const router = useRouter()
const ganttContainer = ref<HTMLElement | null>(null)
const message = useMessage()
let ganttEventIds: string[] = []
let dragEventIds: string[] = []
const tableData = ref<any[]>([])
const projectId = ref<number | null>(null)
const saving = ref(false)
const drawerShow = ref(false)
const selectedProcess = ref<KingdeeProcessRoute | null>(null)
const selectedOrder = ref<KingdeePrdMo | null>(null)
const selectedMoIndex = ref(0)
const selectedRouteIndex = ref(0)
const PROCESS_COLORS = [
'#409eff', '#67c23a', '#e6a23c', '#909399', '#f56c6c',
@ -69,15 +97,39 @@ function formatDayScale(date: Date) {
const scheduleStats = computed(() => {
let routeCount = 0
let assignedCount = 0
tableData.value.forEach((mo) => {
routeCount += mo.processRoutes?.length ?? 0
const routes = mo.processRoutes ?? []
routeCount += routes.length
assignedCount += routes.filter((r: any) => r.deviceId).length
})
return {
moCount: tableData.value.length,
routeCount,
assignedCount,
}
})
const draftAssigned = computed<DraftAssignedItem[]>(() => {
const list: DraftAssignedItem[] = []
tableData.value.forEach((mo, moIndex) => {
(mo.processRoutes ?? []).forEach((route: any, routeIndex: number) => {
if (!route.deviceId) return
list.push({
moIndex,
routeIndex,
orderNo: mo.billNo || mo.productionOrderNo || '',
processName: resolveProcessName(route),
quantity: mo.quantity,
deviceId: route.deviceId,
planStartTime: route.planStartTime,
planFinishTime: route.planFinishTime,
})
})
})
return list
})
const todayLabel = computed(() => {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
@ -88,6 +140,58 @@ function handleBack() {
router.back()
}
function openProcessDrawer(task: any) {
selectedProcess.value = task?._raw || null
selectedMoIndex.value = Number(task?._moIndex ?? 0)
selectedRouteIndex.value = Number(task?._routeIndex ?? 0)
const parent = task?.parent ? gantt.getTask(task.parent) : null
selectedOrder.value = parent?._raw || null
drawerShow.value = true
}
function persistDraftLocal() {
sessionStorage.setItem('gantt_schedule_data', JSON.stringify(tableData.value))
}
function handleScheduleSave(payload: ScheduleSavePayload) {
const mo = tableData.value[payload.moIndex]
const route = mo?.processRoutes?.[payload.routeIndex]
if (!route) {
message.warning('未找到当前工序,无法保存排产')
return
}
route.deviceId = payload.deviceId
route.deviceCode = payload.deviceCode
route.deviceName = payload.deviceName
route.planStartTime = payload.planStartTime
route.planFinishTime = payload.planFinishTime
payload.draftUpdates.forEach((item) => {
const other = tableData.value[item.moIndex]?.processRoutes?.[item.routeIndex]
if (!other) return
other.planStartTime = item.planStartTime
other.planFinishTime = item.planFinishTime
})
if (mo.processRoutes?.length) {
const starts = mo.processRoutes
.map((r: any) => r.planStartTime)
.filter(Boolean)
.sort()
const ends = mo.processRoutes
.map((r: any) => r.planFinishTime)
.filter(Boolean)
.sort()
if (starts.length) mo.planStartTime = starts[0]
if (ends.length) mo.planFinishTime = ends[ends.length - 1]
}
drawerShow.value = false
persistDraftLocal()
renderGanttData()
message.success(`已将工序排到 ${payload.deviceName || payload.deviceCode},请再点击「保存排产草稿」落库`)
}
async function handleSaveDraft() {
if (!projectId.value) {
message.warning('缺少项目ID无法保存')
@ -213,15 +317,35 @@ function initGantt() {
return formatGridDate(task.end_date)
},
},
{
name: 'scheduleStatus',
label: '状态',
align: 'center',
width: 80,
template(task: any) {
if (task._type !== 'route') return ''
return task._raw?.deviceId ? '已排产' : '待排产'
},
},
{
name: 'deviceName',
label: '设备/工位',
align: 'center',
width: 120,
template(task: any) {
if (task._type !== 'route') return ''
return task._raw?.deviceName || '未分配'
},
},
]
gantt.config.layout = {
css: 'gantt_container',
cols: [
{
width: 620,
minWidth: 420,
maxWidth: 680,
width: 820,
minWidth: 520,
maxWidth: 960,
rows: [
{ view: 'grid', scrollX: 'gridScroll', scrollable: true, scrollY: 'scrollVer' },
{ view: 'scrollbar', id: 'gridScroll', group: 'horizontal' },
@ -240,6 +364,8 @@ function initGantt() {
gantt.config.drag_progress = false
gantt.config.drag_links = false
gantt.config.details_on_dblclick = false
gantt.config.details_on_create = false
// 使
gantt.config.work_time = false
gantt.config.correct_work_time = false
@ -260,7 +386,8 @@ function initGantt() {
gantt.templates.task_class = function(_start, _end, task) {
if (task._type === 'mo') return 'gantt-task-mo'
if (task._type === 'route') {
return `gantt-task-route gantt-route-c-${task.colorIndex ?? 0}`
const assigned = !!task._raw?.deviceId
return `gantt-task-route gantt-route-c-${task.colorIndex ?? 0}${assigned ? '' : ' gantt-task-unassigned'}`
}
return ''
}
@ -280,6 +407,18 @@ function initGantt() {
}
gantt.init(ganttContainer.value)
const evClick = gantt.attachEvent('onTaskClick', (id: string | number) => {
const task = gantt.getTask(id)
if (task?._type === 'route') {
openProcessDrawer(task)
}
return true
})
ganttEventIds.push(evClick)
const evDbl = gantt.attachEvent('onTaskDblClick', () => false)
ganttEventIds.push(evDbl)
}
/** 今日时间轴标记(数据刷新后需重新添加) */
@ -357,6 +496,8 @@ function renderGanttData() {
end_date: formatGanttDate(rEnd),
parent: moId,
_type: 'route',
_moIndex: moIndex,
_routeIndex: index,
progress: 0,
_raw: route,
})
@ -367,11 +508,14 @@ function renderGanttData() {
gantt.parse({ data: tasks, links: [] })
addTodayMarker()
dragEventIds.forEach(id => gantt.detachEvent(id))
dragEventIds = []
const evDragId = gantt.attachEvent('onBeforeTaskDrag', (id) => {
const task = gantt.getTask(id)
return task.type !== gantt.config.types.project
})
ganttEventIds.push(evDragId)
dragEventIds.push(evDragId)
const evId = gantt.attachEvent('onAfterTaskDrag', (id, mode) => {
const task = gantt.getTask(id)
@ -417,11 +561,12 @@ function renderGanttData() {
message.success(`任务 [${label}] 计划时间已更新`)
}
})
ganttEventIds.push(evId)
dragEventIds.push(evId)
}
onUnmounted(() => {
ganttEventIds.forEach(id => gantt.detachEvent(id))
dragEventIds.forEach(id => gantt.detachEvent(id))
gantt.clearAll()
})
</script>
@ -604,10 +749,53 @@ onUnmounted(() => {
}
.gantt-container :deep(.gantt_task_line.gantt-task-route) {
border: none !important;
border-radius: 3px;
}
.gantt-container :deep(.gantt_task_line.gantt-task-unassigned) {
background: #fff !important;
border: 1px dashed #c0c4cc !important;
}
.gantt-container :deep(.gantt_task_line.gantt-task-unassigned .gantt_task_content) {
color: #86909c !important;
}
.gantt-legend {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 10px;
padding: 0 4px;
font-size: 12px;
color: #606266;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-item .lg {
display: inline-block;
width: 14px;
height: 10px;
border-radius: 2px;
}
.legend-item .lg.unassigned {
background: #fff;
border: 1px dashed #c0c4cc;
}
.legend-item .lg.pending { background: #c0c4cc; }
.legend-item .lg.assigned { background: #2080f0; }
.legend-item .lg.producing { background: #18a058; }
.legend-item .lg.paused { background: #f0a020; }
.legend-item .lg.done { background: #8b5cf6; }
.gantt-container :deep(.gantt_grid_data .gantt_cell) {
font-size: 13px;
font-weight: 400;

View File

@ -0,0 +1,783 @@
<template>
<n-drawer v-model:show="visible" :width="760" placement="right" display-directive="show">
<n-drawer-content title="工序排产" closable :native-scrollbar="false">
<n-spin :show="loading">
<!-- A. 基本信息 -->
<section class="ps-section">
<h3 class="ps-title">基本信息</h3>
<div class="ps-info-grid">
<div class="ps-info-item">
<span class="k">生产订单</span>
<span class="v">{{ orderNo }}</span>
</div>
<div class="ps-info-item">
<span class="k">工序号</span>
<span class="v">{{ process?.operNumber ?? '-' }}</span>
</div>
<div class="ps-info-item">
<span class="k">工作中心</span>
<span class="v">{{ process?.workCenterName || '-' }}</span>
</div>
<div class="ps-info-item">
<span class="k">工序类型</span>
<span class="v">{{ process?.optCtrlCodeName || process?.processName || '机加' }}</span>
</div>
<div class="ps-info-item">
<span class="k">计划工时</span>
<span class="v">{{ manHourText }}</span>
</div>
<div class="ps-info-item">
<span class="k">计划数量</span>
<span class="v">{{ order?.quantity ?? '-' }}</span>
</div>
<div class="ps-info-item">
<span class="k">计划开始</span>
<span class="v">{{ formatDateTime(process?.planStartTime) }}</span>
</div>
<div class="ps-info-item">
<span class="k">计划结束</span>
<span class="v">{{ formatDateTime(process?.planFinishTime) }}</span>
</div>
<div class="ps-info-item">
<span class="k">当前状态</span>
<n-tag :type="currentAssigned ? 'info' : 'default'" size="small" :bordered="false">
{{ currentAssigned ? '已排产' : '待排产' }}
</n-tag>
</div>
</div>
</section>
<!-- B. 可执行设备/工位 -->
<section class="ps-section">
<h3 class="ps-title">可执行设备 / 工位</h3>
<p v-if="matchHint" class="ps-hint">{{ matchHint }}</p>
<n-data-table
size="small"
:columns="deviceColumns"
:data="devices"
:row-key="(row: DeviceScheduleItemVO) => row.id"
:bordered="false"
:single-line="false"
:row-class-name="deviceRowClass"
:row-props="deviceRowProps"
max-height="220"
/>
<n-empty v-if="!loading && !devices.length" description="未找到可执行设备/工位" size="small" style="margin: 12px 0" />
</section>
<!-- C. 当前任务 -->
<section class="ps-section">
<div class="ps-section-head">
<h3 class="ps-title">{{ selectedDeviceName }} 当前任务</h3>
<n-button size="tiny" quaternary :disabled="!selectedDevice" @click="recalcTimes">
刷新计算时间
</n-button>
</div>
<n-tabs v-model:value="taskTab" size="small" type="line" :pane-style="{ display: 'none' }">
<n-tab-pane name="all" :tab="`全部任务 (${taskQueue.length})`" />
<n-tab-pane name="producing" :tab="`生产中 (${countByKey('producing')})`" />
<n-tab-pane name="assigned" :tab="`已排产 (${countByKey('assigned')})`" />
<n-tab-pane name="pending" :tab="`待排产 (${countByKey('pending')})`" />
</n-tabs>
<div v-if="!selectedDevice" class="ps-empty-tip">请先在上方选择一台设备/工位</div>
<VueDraggable
v-else-if="taskTab === 'all'"
v-model="taskQueue"
:animation="160"
handle=".ps-drag"
class="ps-task-list"
@end="recalcTimes"
>
<div
v-for="task in taskQueue"
:key="String(task.id)"
class="ps-task-row"
:class="{ current: task.isCurrent, locked: task.locked }"
>
<n-icon class="ps-drag" size="16" :depth="3"><MenuOutline /></n-icon>
<span class="ps-seq">{{ displayIndex(task) }}</span>
<span class="ps-play" :class="task.statusKey">
<n-icon size="14"><PlayOutline /></n-icon>
</span>
<div class="ps-task-main">
<div class="ps-task-code">{{ task.materialCode || task.orderNo || '-' }}</div>
<div class="ps-task-sub">{{ task.operNumber ?? '-' }} {{ task.processName || '' }}</div>
</div>
<span class="ps-qty">{{ task.quantity ?? '-' }}</span>
<n-tag size="tiny" :type="statusTagType(task.statusKey)" :bordered="false">
{{ task.statusText || task.statusLabel || '-' }}
</n-tag>
<div class="ps-time">
<div>{{ formatShort(task.planStartTime) }}</div>
<div class="sub">{{ formatShort(task.planFinishTime) }}</div>
</div>
</div>
<div v-if="!taskQueue.length" class="ps-empty-tip">暂无任务</div>
</VueDraggable>
<div v-else class="ps-task-list">
<div
v-for="task in filteredTasks"
:key="String(task.id)"
class="ps-task-row"
:class="{ current: task.isCurrent, locked: task.locked }"
>
<n-icon class="ps-drag" size="16" :depth="3" style="visibility:hidden"><MenuOutline /></n-icon>
<span class="ps-seq">{{ displayIndex(task) }}</span>
<span class="ps-play" :class="task.statusKey">
<n-icon size="14"><PlayOutline /></n-icon>
</span>
<div class="ps-task-main">
<div class="ps-task-code">{{ task.materialCode || task.orderNo || '-' }}</div>
<div class="ps-task-sub">{{ task.operNumber ?? '-' }} {{ task.processName || '' }}</div>
</div>
<span class="ps-qty">{{ task.quantity ?? '-' }}</span>
<n-tag size="tiny" :type="statusTagType(task.statusKey)" :bordered="false">
{{ task.statusText || task.statusLabel || '-' }}
</n-tag>
<div class="ps-time">
<div>{{ formatShort(task.planStartTime) }}</div>
<div class="sub">{{ formatShort(task.planFinishTime) }}</div>
</div>
</div>
<div v-if="!filteredTasks.length" class="ps-empty-tip">该筛选下暂无任务</div>
</div>
</section>
<!-- D. 时间预览 -->
<section class="ps-section">
<h3 class="ps-title">时间预览</h3>
<div v-if="!previewRows.length" class="ps-empty-tip">选择设备后可预览 24 小时负荷</div>
<div v-else class="ps-preview">
<div class="ps-preview-head">
<span class="ps-preview-label" />
<div class="ps-preview-hours">
<span v-for="h in hourLabels" :key="h">{{ h }}</span>
</div>
</div>
<div v-for="row in previewRows" :key="row.date" class="ps-preview-row">
<span class="ps-preview-label">{{ row.date }}</span>
<div class="ps-preview-track">
<div
v-for="(seg, i) in row.segments"
:key="i"
class="ps-block"
:class="seg.statusKey"
:style="{ left: seg.left, width: seg.width }"
:title="seg.title"
>
{{ seg.label }}
</div>
</div>
</div>
</div>
</section>
</n-spin>
<template #footer>
<n-space justify="end">
<n-button @click="visible = false">取消</n-button>
<n-button type="primary" :disabled="!selectedDevice" @click="handleSave">
保存排产顺序
</n-button>
</n-space>
</template>
</n-drawer-content>
</n-drawer>
</template>
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import { NButton, NTag, useMessage, type DataTableColumns } from 'naive-ui'
import { MenuOutline, PlayOutline } from '@vicons/ionicons5'
import { VueDraggable } from 'vue-draggable-plus'
import {
deviceApi,
type DeviceScheduleItemVO,
type DeviceScheduleTaskVO,
} from '@/api/device'
import { resolveProcessName, type KingdeePrdMo, type KingdeeProcessRoute } from '@/api/orderProject'
export interface DraftAssignedItem {
moIndex: number
routeIndex: number
orderNo: string
processName: string
quantity?: number | null
deviceId: number | string
planStartTime?: string | null
planFinishTime?: string | null
}
export interface ScheduleSavePayload {
moIndex: number
routeIndex: number
deviceId: number
deviceCode?: string | null
deviceName?: string | null
planStartTime: string
planFinishTime: string
draftUpdates: Array<{
moIndex: number
routeIndex: number
planStartTime: string
planFinishTime: string
}>
}
const props = withDefaults(defineProps<{
show: boolean
process?: KingdeeProcessRoute | Record<string, any> | null
order?: KingdeePrdMo | Record<string, any> | null
moIndex?: number
routeIndex?: number
draftAssigned?: DraftAssignedItem[]
}>(), {
process: () => ({}),
order: () => ({}),
moIndex: 0,
routeIndex: 0,
draftAssigned: () => [],
})
const emit = defineEmits<{
'update:show': [boolean]
save: [payload: ScheduleSavePayload]
}>()
const message = useMessage()
const visible = computed({
get: () => props.show,
set: (v) => emit('update:show', v),
})
const loading = ref(false)
const matchHint = ref('')
const devices = ref<DeviceScheduleItemVO[]>([])
const selectedDevice = ref<DeviceScheduleItemVO | null>(null)
const backendTasks = ref<DeviceScheduleTaskVO[]>([])
const taskQueue = ref<DeviceScheduleTaskVO[]>([])
const taskTab = ref<'all' | 'producing' | 'assigned' | 'pending'>('all')
const orderNo = computed(() =>
props.order?.productionOrderNo || props.order?.billNo || props.order?.materialCode || '-'
)
const manHourText = computed(() => {
const qty = props.process?.activityQty
const unit = props.process?.activityUnit || 'h'
if (qty == null || qty === '') return '-'
return `${qty}${unit}`
})
const currentAssigned = computed(() => !!props.process?.deviceId)
const selectedDeviceName = computed(() => selectedDevice.value?.deviceName || selectedDevice.value?.deviceCode || '设备')
const currentKey = computed(() => `draft_${props.moIndex}_${props.routeIndex}`)
const filteredTasks = computed(() => {
if (taskTab.value === 'all') return taskQueue.value
return taskQueue.value.filter((t) => t.statusKey === taskTab.value)
})
const hourLabels = ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00']
const deviceColumns: DataTableColumns<DeviceScheduleItemVO> = [
{
title: '设备/工位',
key: 'deviceName',
minWidth: 140,
render: (row) => h('div', [
h('div', { style: 'font-weight:600;color:#303133' }, row.deviceName || row.deviceCode || '-'),
h('div', { style: 'font-size:12px;color:#909399' }, row.deviceCode || row.deviceFlagLabel || ''),
]),
},
{ title: '工作中心', key: 'sectionName', width: 110, render: (row) => row.sectionName || '-' },
{
title: '状态',
key: 'runStatus',
width: 72,
render: (row) => h(NTag, {
size: 'small',
type: row.runStatus === 'busy' ? 'warning' : 'success',
bordered: false,
}, { default: () => row.runStatusLabel || (row.runStatus === 'busy' ? '忙碌' : '闲置') }),
},
{
title: '当前负荷',
key: 'loadPercent',
width: 80,
render: (row) => `${row.loadPercent ?? 0}%`,
},
{
title: '操作',
key: 'actions',
width: 100,
render: (row) => h(NButton, {
size: 'tiny',
type: selectedDevice.value?.id === row.id ? 'primary' : 'default',
secondary: selectedDevice.value?.id !== row.id,
onClick: (e: MouseEvent) => {
e.stopPropagation()
selectDevice(row)
},
}, { default: () => selectedDevice.value?.id === row.id ? '已选择' : '选择排产' }),
},
]
function deviceRowClass(row: DeviceScheduleItemVO) {
return selectedDevice.value?.id === row.id ? 'ps-device-selected' : ''
}
function deviceRowProps(row: DeviceScheduleItemVO) {
return {
style: 'cursor: pointer',
onClick: () => selectDevice(row),
}
}
function countByKey(key: string) {
return taskQueue.value.filter((t) => t.statusKey === key).length
}
function displayIndex(task: DeviceScheduleTaskVO) {
return taskQueue.value.findIndex((t) => t.id === task.id) + 1
}
function statusTagType(key?: string) {
if (key === 'producing') return 'success'
if (key === 'assigned') return 'info'
if (key === 'pending') return 'warning'
if (key === 'paused') return 'warning'
if (key === 'done') return 'default'
return 'default'
}
function parseTime(value?: string | null) {
if (!value) return null
const d = new Date(String(value).replace(' ', 'T'))
return Number.isNaN(d.getTime()) ? null : d
}
function pad(n: number) {
return String(n).padStart(2, '0')
}
function formatDateTime(value?: string | null) {
const d = parseTime(value)
if (!d) return '-'
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function formatShort(value?: string | null) {
const d = parseTime(value)
if (!d) return '-'
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function formatGanttDate(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function durationOf(start?: string | null, end?: string | null, fallback = 8 * 3600000) {
const s = parseTime(start)
const e = parseTime(end)
if (!s || !e || e <= s) return fallback
return e.getTime() - s.getTime()
}
function md(date: Date) {
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
watch(
() => props.show,
async (show) => {
if (!show) return
selectedDevice.value = null
backendTasks.value = []
taskQueue.value = []
taskTab.value = 'all'
await loadCapable()
},
)
async function loadCapable() {
loading.value = true
try {
const res = await deviceApi.scheduleCapable({
workCenterName: props.process?.workCenterName,
departmentName: props.process?.departmentName || props.order?.workShopName,
})
matchHint.value = res?.matchHint || ''
devices.value = res?.devices || []
const boundId = props.process?.deviceId
if (boundId != null) {
const hit = devices.value.find((d) => String(d.id) === String(boundId))
if (hit) await selectDevice(hit)
}
} catch {
matchHint.value = '加载可执行设备失败'
devices.value = []
} finally {
loading.value = false
}
}
async function selectDevice(row: DeviceScheduleItemVO) {
selectedDevice.value = row
loading.value = true
try {
const list = await deviceApi.scheduleTasks(row.id)
backendTasks.value = Array.isArray(list) ? list : []
} catch {
backendTasks.value = []
} finally {
loading.value = false
}
buildQueue()
}
function buildQueue() {
const deviceId = selectedDevice.value?.id
const durationMs = durationOf(props.process?.planStartTime, props.process?.planFinishTime)
const current: DeviceScheduleTaskVO = {
id: currentKey.value,
orderNo: orderNo.value,
materialCode: props.order?.materialCode,
materialName: props.order?.materialName,
processName: resolveProcessName(props.process || {}),
operNumber: props.process?.operNumber,
quantity: props.order?.quantity ?? null,
statusKey: 'pending',
statusText: '待排产',
statusLabel: '待排产',
planStartTime: props.process?.planStartTime,
planFinishTime: props.process?.planFinishTime,
locked: false,
isCurrent: true,
durationMs,
}
const fromBackend = backendTasks.value.map((t) => ({
...t,
isCurrent: false,
durationMs: durationOf(t.planStartTime, t.planFinishTime),
}))
const fromDraft = (props.draftAssigned || []).filter((t) => {
if (String(t.deviceId) !== String(deviceId)) return false
if (t.moIndex === props.moIndex && t.routeIndex === props.routeIndex) return false
return true
}).map((t) => ({
id: `draft_${t.moIndex}_${t.routeIndex}`,
orderNo: t.orderNo,
processName: t.processName,
quantity: t.quantity,
deviceId: String(t.deviceId),
statusKey: 'assigned',
statusText: '已排产',
statusLabel: '已排产',
planStartTime: t.planStartTime,
planFinishTime: t.planFinishTime,
locked: false,
isCurrent: false,
durationMs: durationOf(t.planStartTime, t.planFinishTime),
moIndex: t.moIndex,
routeIndex: t.routeIndex,
} as DeviceScheduleTaskVO & { moIndex: number; routeIndex: number }))
const merged: DeviceScheduleTaskVO[] = []
const seen = new Set<string>()
for (const t of [...fromBackend, ...fromDraft]) {
const key = String(t.id)
if (seen.has(key)) continue
seen.add(key)
merged.push(t)
}
const already = merged.findIndex((t) =>
String(t.materialCode) === String(props.order?.materialCode)
&& String(t.operNumber) === String(props.process?.operNumber)
)
if (already >= 0) {
merged[already] = { ...merged[already], ...current, id: merged[already].id }
} else {
const insertAt = merged.findIndex((t) => !t.locked)
if (insertAt < 0) merged.push(current)
else merged.splice(insertAt, 0, current)
}
taskQueue.value = merged
recalcTimes()
}
function recalcTimes() {
const list = taskQueue.value
if (!list.length) return
let cursor: Date | null = null
for (const task of list) {
const dur = task.durationMs || durationOf(task.planStartTime, task.planFinishTime)
if (task.locked && parseTime(task.planStartTime) && parseTime(task.planFinishTime)) {
const end = parseTime(task.planFinishTime)!
if (!cursor || end > cursor) cursor = end
continue
}
if (!cursor) {
cursor = parseTime(task.planStartTime) || new Date()
}
const start = new Date(cursor)
const end = new Date(start.getTime() + dur)
task.planStartTime = formatGanttDate(start)
task.planFinishTime = formatGanttDate(end)
cursor = end
}
}
const previewRows = computed(() => {
const map = new Map<string, { date: string; segments: Array<{
left: string; width: string; statusKey: string; label: string; title: string
}> }>()
for (const task of taskQueue.value) {
const start = parseTime(task.planStartTime)
const end = parseTime(task.planFinishTime)
if (!start || !end || end <= start) continue
let cursor = new Date(start)
while (cursor < end) {
const dayStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())
const dayEnd = new Date(dayStart.getTime() + 86400000)
const segEnd = end < dayEnd ? end : dayEnd
const leftMin = (cursor.getTime() - dayStart.getTime()) / 60000
const widthMin = Math.max(20, (segEnd.getTime() - cursor.getTime()) / 60000)
const date = md(cursor)
if (!map.has(date)) map.set(date, { date, segments: [] })
map.get(date)!.segments.push({
left: `${(leftMin / 1440) * 100}%`,
width: `${Math.min(100, (widthMin / 1440) * 100)}%`,
statusKey: task.isCurrent ? 'pending' : (task.statusKey || 'assigned'),
label: String(task.materialCode || task.orderNo || task.processName || ''),
title: `${task.materialCode || task.orderNo || ''} ${task.processName || ''} ${formatShort(task.planStartTime)} ~ ${formatShort(task.planFinishTime)}`,
})
cursor = segEnd
}
}
return [...map.values()]
})
function handleSave() {
if (!selectedDevice.value) {
message.warning('请先选择可执行设备/工位')
return
}
const current = taskQueue.value.find((t) => t.isCurrent)
if (!current?.planStartTime || !current?.planFinishTime) {
message.warning('请先刷新计算时间')
return
}
emit('save', {
moIndex: props.moIndex,
routeIndex: props.routeIndex,
deviceId: selectedDevice.value.id,
deviceCode: selectedDevice.value.deviceCode,
deviceName: selectedDevice.value.deviceName,
planStartTime: current.planStartTime,
planFinishTime: current.planFinishTime,
draftUpdates: taskQueue.value
.filter((t) => !t.isCurrent && String(t.id).startsWith('draft_') && t.planStartTime && t.planFinishTime)
.map((t) => {
const parts = String(t.id).split('_')
return {
moIndex: Number(parts[1]),
routeIndex: Number(parts[2]),
planStartTime: t.planStartTime as string,
planFinishTime: t.planFinishTime as string,
}
})
.filter((u) => Number.isFinite(u.moIndex) && Number.isFinite(u.routeIndex)),
})
}
</script>
<style scoped>
.ps-section {
margin-bottom: 18px;
}
.ps-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ps-title {
margin: 0 0 10px;
padding-left: 8px;
border-left: 3px solid #2f6fed;
font-size: 14px;
font-weight: 600;
color: #303133;
line-height: 1.2;
}
.ps-hint {
margin: -4px 0 8px;
font-size: 12px;
color: #909399;
}
.ps-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
padding: 12px;
background: #f7f8fa;
border-radius: 6px;
}
.ps-info-item {
display: flex;
gap: 8px;
min-width: 0;
font-size: 13px;
}
.ps-info-item .k {
color: #909399;
flex-shrink: 0;
}
.ps-info-item .v {
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ps-empty-tip {
padding: 16px 0;
text-align: center;
color: #909399;
font-size: 13px;
}
.ps-task-list {
margin-top: 8px;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
}
.ps-task-row {
display: grid;
grid-template-columns: 22px 28px 22px minmax(0, 1fr) 48px 64px 92px;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid #f0f2f5;
background: #fff;
}
.ps-task-row:last-child {
border-bottom: none;
}
.ps-task-row.current {
background: #fff8e6;
}
.ps-drag {
cursor: grab;
color: #c0c4cc;
}
.ps-seq {
font-size: 12px;
color: #909399;
text-align: center;
}
.ps-play {
display: inline-flex;
color: #c0c4cc;
}
.ps-play.producing { color: #18a058; }
.ps-play.assigned { color: #2080f0; }
.ps-play.pending { color: #f0a020; }
.ps-task-code {
font-size: 13px;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ps-task-sub {
font-size: 12px;
color: #909399;
}
.ps-qty {
font-size: 12px;
color: #606266;
text-align: right;
}
.ps-time {
font-size: 12px;
color: #606266;
text-align: right;
line-height: 1.35;
}
.ps-time .sub {
color: #909399;
}
.ps-preview {
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
}
.ps-preview-head,
.ps-preview-row {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
}
.ps-preview-head {
background: #fafafa;
border-bottom: 1px solid #ebeef5;
padding: 6px 8px;
font-size: 12px;
color: #909399;
}
.ps-preview-hours {
display: flex;
justify-content: space-between;
}
.ps-preview-label {
font-size: 12px;
color: #606266;
padding: 0 6px;
}
.ps-preview-row {
min-height: 36px;
border-bottom: 1px solid #f5f7fa;
padding: 6px 8px;
}
.ps-preview-track {
position: relative;
height: 24px;
background:
repeating-linear-gradient(
90deg,
#f5f7fa 0,
#f5f7fa calc(100% / 6 - 1px),
#ebeef5 calc(100% / 6 - 1px),
#ebeef5 calc(100% / 6)
);
border-radius: 3px;
}
.ps-block {
position: absolute;
top: 2px;
height: 20px;
border-radius: 3px;
font-size: 11px;
line-height: 20px;
color: #fff;
padding: 0 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ps-block.producing { background: #18a058; }
.ps-block.assigned { background: #2080f0; }
.ps-block.pending { background: #f0a020; }
.ps-block.paused { background: #f0a020; }
.ps-block.done { background: #8b5cf6; }
:deep(.ps-device-selected td) {
background: #ecf5ff !important;
}
</style>

View File

@ -7,7 +7,13 @@
<template #icon><n-icon><AddOutline /></n-icon></template>
新建工序委外
</n-button>
<n-button disabled title="待后端接口支持">下推</n-button>
<n-button
type="primary"
ghost
:disabled="selectedIds.length !== 1"
title="需先填写供应商,已下推不可再推"
@click="handlePushKingdee()"
>下推金蝶</n-button>
<n-button type="error" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
<template #icon><n-icon><TrashOutline /></n-icon></template>
批量删除
@ -58,7 +64,7 @@
:loading="loading"
:pagination="pagination"
:row-key="(row) => row.id!"
:scroll-x="1520"
:scroll-x="1720"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:checked-row-keys="handleCheck"
@ -82,8 +88,21 @@
<n-descriptions-item label="合格数量">{{ formatDecimal(detailData.qualifiedQuantity) }}</n-descriptions-item>
<n-descriptions-item label="预计开始">{{ detailData.beginPreparationTime || '-' }}</n-descriptions-item>
<n-descriptions-item label="预计结束">{{ detailData.endPreparationTime || '-' }}</n-descriptions-item>
<n-descriptions-item label="下推金蝶">{{ detailData.kingdeePushed === 1 ? '已下推' : '未下推' }}</n-descriptions-item>
<n-descriptions-item label="金蝶单号">{{ detailData.kingdeeBillNo || '-' }}</n-descriptions-item>
<n-descriptions-item label="备注" :span="2">{{ detailData.remark || '-' }}</n-descriptions-item>
</n-descriptions>
<div v-if="detailData.details?.length" style="margin-top: 16px">
<div style="margin-bottom: 8px; font-weight: 600">分批明细</div>
<n-data-table
size="small"
:bordered="true"
:single-line="false"
:data="detailData.details"
:columns="detailColumns"
:pagination="false"
/>
</div>
</n-modal>
<!-- 编辑 -->
@ -97,10 +116,16 @@
<n-input :value="formData.name" disabled />
</n-form-item-gi>
<n-form-item-gi label="供应商" path="supplierName">
<n-input v-model:value="formData.supplierName" placeholder="请输入供应商" />
<n-input v-model:value="formData.supplierName" placeholder="空则为草稿,补齐后可下推金蝶" />
</n-form-item-gi>
<n-form-item-gi label="委外数量" path="quantity">
<n-input-number v-model:value="formData.quantity" :min="1" :precision="0" style="width: 100%" />
<n-input-number
v-model:value="formData.quantity"
:min="1"
:precision="0"
:disabled="formData.kingdeePushed === 1 || !!formData.details?.length"
style="width: 100%"
/>
</n-form-item-gi>
<n-form-item-gi label="单价" path="unitPrice">
<n-input-number v-model:value="formData.unitPrice" :min="0" :precision="2" style="width: 100%" />
@ -202,11 +227,12 @@ const defaultFormData = (): Record<string, unknown> => ({
beginPreparationTime: null,
endPreparationTime: null,
remark: '',
kingdeePushed: 0,
details: [],
})
const formData = reactive<Record<string, unknown>>(defaultFormData())
const formRules: FormRules = {
supplierName: [{ required: true, message: '请输入供应商', trigger: 'blur' }],
quantity: [{ required: true, type: 'number', message: '请输入委外数量', trigger: ['blur', 'change'] }],
}
@ -358,18 +384,40 @@ const columns: DataTableColumns<Outsourcing> = [
])
},
},
{
title: '下推金蝶',
key: 'kingdeePushed',
width: 100,
align: 'center',
render(row) {
const pushed = row.kingdeePushed === 1
return h(NTag, {
size: 'small',
type: pushed ? 'success' : 'default',
bordered: false,
}, { default: () => pushed ? '已下推' : '未下推' })
},
},
{
title: '操作',
key: 'actions',
width: 120,
width: 168,
align: 'center',
fixed: 'right',
render(row) {
const canPush = row.kingdeePushed !== 1 && !!row.supplierName
return h('div', { class: 'cell-center' }, [
h(NSpace, { size: 4, justify: 'center' }, {
default: () => [
h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => openDetail(row) }, { default: () => '详情' }),
h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(NButton, {
text: true,
type: 'primary',
size: 'small',
disabled: !canPush,
onClick: () => handlePushKingdee(row.id),
}, { default: () => '下推' }),
],
}),
])
@ -377,6 +425,13 @@ const columns: DataTableColumns<Outsourcing> = [
},
]
const detailColumns: DataTableColumns<{ quantity?: number; remark?: string; createTime?: string; flowingAroundId?: number }> = [
{ title: '批次数量', key: 'quantity', width: 90, align: 'center' },
{ title: '来源', key: 'remark', ellipsis: { tooltip: true } },
{ title: '流转确认ID', key: 'flowingAroundId', width: 110, align: 'center', render: (row) => row.flowingAroundId ?? '-' },
{ title: '时间', key: 'createTime', width: 160, align: 'center', render: (row) => row.createTime || '-' },
]
async function loadData() {
loading.value = true
try {
@ -440,13 +495,15 @@ async function openDetail(row: Outsourcing) {
detailVisible.value = true
}
function handleEdit(row: Outsourcing) {
async function handleEdit(row: Outsourcing) {
if (!row.id) return
modalTitle.value = '编辑委外工单'
Object.assign(formData, defaultFormData(), row, {
quantity: row.quantity ?? null,
unitPrice: row.unitPrice != null ? Number(row.unitPrice) : null,
beginPreparationTime: parseDateTime(row.beginPreparationTime),
endPreparationTime: parseDateTime(row.endPreparationTime),
const full = await outsourcingApi.detail(row.id)
Object.assign(formData, defaultFormData(), full, {
quantity: full.quantity ?? null,
unitPrice: full.unitPrice != null ? Number(full.unitPrice) : null,
beginPreparationTime: parseDateTime(full.beginPreparationTime),
endPreparationTime: parseDateTime(full.endPreparationTime),
})
modalVisible.value = true
}
@ -483,6 +540,34 @@ async function handleSubmit() {
}
}
function handlePushKingdee(id?: number) {
const targetId = id ?? selectedIds.value[0]
if (!targetId) {
message.warning('请选择一张委外单')
return
}
const row = tableData.value.find((r) => r.id === targetId)
if (row?.kingdeePushed === 1) {
message.warning('该委外单已下推金蝶')
return
}
if (!row?.supplierName) {
message.warning('请先填写供应商,再下推金蝶')
return
}
dialog.warning({
title: '下推金蝶',
content: `确定下推委外单 ${row.outsourceCode || targetId} 吗?下推后不能再改数量。`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
await outsourcingApi.pushKingdee(targetId)
message.success('已标记下推金蝶')
loadData()
},
})
}
function handleBatchDelete() {
dialog.warning({
title: '提示',

View File

@ -1,19 +1,20 @@
<template>
<div>
<n-scrollbar x-scrollable>
<n-data-table
<n-data-table
v-model:expanded-row-keys="AssingWorkExpandedKeys"
:columns="AssingWorkColumns"
:data="AssingWorkTableData"
:loading="tableLoading"
:row-key="RowKey"
:scroll-x="1700"
:scroll-x="1920"
:max-height="1000"
size="small"
striped
:single-line="true"
class="assing-work-table"
style="margin-bottom:15px"
@update-expanded-row-keys="handleExpandChange"
/>
</n-scrollbar>
<div class="pagination-container">
<n-button
@ -40,8 +41,14 @@
</n-pagination>
</div>
<!--完工汇报弹窗-->
<n-modal v-model:show="showQcReport" :title="reportTitle" style="width: 1000px" >
<div>
<n-modal
v-model:show="showQcReport"
preset="card"
:title="reportTitle"
:bordered="false"
style="width: 920px"
:segmented="{ content: true, footer: true }"
>
<SubmitLogInfoPage
v-if="baseInfo.logOrQuality === 'log'"
:assingId="assingId"
@ -52,13 +59,9 @@
:assingId="assingId"
:baseInfo="baseInfo"
/>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="showQcReport = false">取消</n-button>
<n-button @click="showQcReport = false">关闭</n-button>
</n-space>
</template>
</n-modal>
@ -78,6 +81,10 @@
<n-descriptions-item label="是否返工">{{ handlerReWork(assingWorkDetail.rework) ?? '-' }}</n-descriptions-item>
<n-descriptions-item label="返工数量">{{ assingWorkDetail.reworkNum ?? '-' }}</n-descriptions-item>
<n-descriptions-item label="派工状态">{{ handlerPatchStatus(assingWorkDetail.assingStatus) ?? '-' }}</n-descriptions-item>
<n-descriptions-item label="暂停时间">{{ formatWorkSeconds(assingWorkDetail.pauseSeconds) }}</n-descriptions-item>
<n-descriptions-item label="调车时间">{{ formatWorkSeconds(assingWorkDetail.shuntSeconds) }}</n-descriptions-item>
<n-descriptions-item label="实际工时">{{ formatWorkSeconds(assingWorkDetail.actualWorkSeconds) }}</n-descriptions-item>
<n-descriptions-item label="单件工时">{{ formatWorkSeconds(assingWorkDetail.unitWorkSeconds) }}</n-descriptions-item>
</n-descriptions>
@ -104,13 +111,56 @@ import {
import { mytasks,type AssingWork } from '@/api/production';
import {
NButton, NSpace, NTag, NDataTable, DataTableColumns, NDescriptions, NDescriptionsItem, NPagination
NButton, NSpace, NTag, NDataTable, NIcon, DataTableColumns, NDescriptions, NDescriptionsItem, NPagination
} from 'naive-ui'
import { DocumentTextOutline, ShieldCheckmarkOutline } from '@vicons/ionicons5'
import { dictDataApi } from '@/api/org'
import { assingWorkDetailApi,AssingWorkDetail } from '@/api/AssingWorkDetail'
import SubmitLogInfoPage from "@/views/production/componets/SubmitLogInfoPage.vue";
import QualityInfoPage from "@/views/production/componets/QualityInfoPage.vue";
import Button from "naive-ui/es/button/src/Button";
import { formatWorkSeconds, getaDtetime } from '@/utils/tool'
function dash(value: unknown) {
if (value === 0) return '0'
if (value == null || value === '') return '-'
return String(value)
}
function renderInline(primary?: unknown, secondary?: unknown) {
const main = dash(primary)
const sub = dash(secondary)
const title = sub === '-' ? main : `${main} ${sub}`
return h('div', { class: 'cell-inline', title }, [
h('span', { class: 'cell-main' }, main),
sub !== '-' ? h('span', { class: 'cell-sub' }, sub) : null
])
}
function renderFlag(label: string, value: unknown) {
const yes = value == 1
const no = value == 0
return h('span', {
class: ['flag-chip', yes ? 'is-yes' : no ? 'is-no' : 'is-empty']
}, `${label}${yes ? '是' : no ? '否' : '-'}`)
}
function compactHours(seconds?: number | string | null) {
return formatWorkSeconds(seconds).replace('小时', 'h').replace('分', 'm').replace('秒', 's')
}
function completedQtyOf(row: any) {
return Number(row.completedQuantity ?? row.completeQuantity) || 0
}
function planQtyOf(row: any) {
return Number(row.quantity) || 0
}
function formatCompletedTime(value: unknown) {
if (!value) return '-'
if (typeof value === 'number' || value instanceof Date) return getaDtetime(value)
return String(value)
}
const defaultBaseInfo = ref({
@ -161,12 +211,7 @@ const AssingWorkColumns :DataTableColumns<AssingWork> = [
{
class: 'route-code-link',
href: 'javascript:;',
style: {
color: '#2080f0',
textDecoration: 'underline',
cursor: 'pointer',
},
onClick: (e: MouseEvent) => {
onClick: () => {
viewAssingWorkDetail(row)
},
},
@ -175,203 +220,144 @@ const AssingWorkColumns :DataTableColumns<AssingWork> = [
}
},
{
align:'center',
title: '工单 / 工序',
key: 'workOrderCode',
width: 240,
render(row: any) {
return renderInline(row.workOrderCode, row.processName)
}
},
{
title: '物料编码',
key: 'materialCode',
width: 120,
ellipsis: { tooltip: true },
render(row:any) {
return row.materialCode || '-'
ellipsis: false,
className: 'material-full-cell',
render(row: any) {
return h('span', { class: 'cell-full' }, dash(row.materialCode))
}
},
{
align:'center',
title: '物料名称',
key: 'materialName',
width: 140,
width: 120,
ellipsis: false,
className: 'material-full-cell',
render(row: any) {
return h('span', { class: 'cell-full' }, dash(row.materialName))
}
},
{
title: '工位 / 设备',
key: 'deviceName',
width: 120,
ellipsis: { tooltip: true },
render(row:any) {
return row.materialName || '-'
render(row: any) {
return dash(row.deviceName)
}
},
{
align:'center',
title: '工单号',
key: 'workOrderCode',
width: 120,
ellipsis: { tooltip: true }
},
{
align:'center',
title: '工序名称',
key: 'processName',
width: 120,
ellipsis: { tooltip: true }
},
/*{
align:'center',
title: '工序编号',
key: 'processCode',
width: 100
}, */
{
align:'center',
title: '是否质检',
align: 'center',
title: '质检 / 入库',
key: 'qualityInspection',
width: 90,
render(row) {
var opt = row.qualityInspection
if (opt == 0) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => "否" })
}
if (opt == 1) {
return h(NTag, { type: 'success', size: 'small' }, { default: () => "是" })
}
width: 126,
render(row: any) {
return h('div', { class: 'flag-group' }, [
renderFlag('质检', row.qualityInspection),
renderFlag('入库', row.storageEntry)
])
}
},
{
align:'center',
title: '是否入库',
key: 'storageEntry',
width: 90,
render(row) {
var opt = row.storageEntry
if (opt == 0) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => "否" })
}
if (opt == 1) {
return h(NTag, { type: 'success', size: 'small' }, { default: () => "是" })
}
}
},
{
align:'center',
align: 'center',
title: '派工状态',
key: 'assingStatus',
width: 90,
render(row:any) {
width: 96,
render(row: any) {
const val = row.assingStatus
const opt = qcAssingStatusOptions.value.find(o => o.value === val || String(o.value) === String(val))
if (!opt) return val ?? '-'
return h(NTag, { type: opt.class, size: 'small' }, { default: () => opt.label })
if (!opt) return dash(val)
return h(NTag, { type: opt.class, size: 'small', bordered: false }, { default: () => opt.label })
}
},
// {
// align:'center',
// title: '',
// key: 'workshopName',
// minWidth: 200
// },
// {
// align:'center',
// title: '',
// key: 'sectionName',
// minWidth: 200
// },
{
align:'center',
title: '工位/设备',
key: 'deviceName',
width: 110,
ellipsis: { tooltip: true }
title: '完成进度',
key: 'quantity',
width: 88,
render(row: any) {
return h('span', { class: 'qty-text' }, `${completedQtyOf(row)}/${planQtyOf(row)}`)
}
},
{
align:'center',
title: '数量',
width: 70,
key: 'quantity'
},
{
align:'center',
title: '完成数量',
key: 'completedQuantity',
width: 80
},
{
align:'center',
title: '完成时间',
key: 'completedTime',
width: 160
width: 156,
render(row: any) {
return h('span', { class: 'time-text' }, formatCompletedTime(row.completedTime))
}
},
{
align:'center',
title: '工时',
key: 'actualWorkSeconds',
width: 200,
ellipsis: { tooltip: true },
render(row: any) {
const title = `暂停 ${formatWorkSeconds(row.pauseSeconds)} 调车 ${formatWorkSeconds(row.shuntSeconds)} 实际 ${formatWorkSeconds(row.actualWorkSeconds)} 单件 ${formatWorkSeconds(row.unitWorkSeconds)}`
return h('div', { class: 'hours-cell', title }, [
h('span', { class: 'hours-item' }, ['停 ', h('b', compactHours(row.pauseSeconds))]),
h('span', { class: 'hours-item' }, ['调 ', h('b', compactHours(row.shuntSeconds))]),
h('span', { class: 'hours-item is-actual' }, ['实 ', h('b', compactHours(row.actualWorkSeconds))]),
h('span', { class: 'hours-item is-unit' }, ['单 ', h('b', compactHours(row.unitWorkSeconds))])
])
}
},
{
align: 'center',
title: '状态',
key: 'status',
width: 80,
render(row:any) {
if(row.status == 0){
return h(NTag, {
type:'success',
size: 'small'
},
{
default: () => '启用'
}
)
width: 84,
render(row: any) {
if (row.status == 0) {
return h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => '启用' })
}
if(row.status == 1){
return h(NTag, {
type:'warning',
size: 'small'
},
{
default: () => '已暂停'
}
)
if (row.status == 1) {
return h(NTag, { type: 'warning', size: 'small', bordered: false }, { default: () => '已暂停' })
}
if(row.status == 2){
return h(NTag, {
type:'warning',
size: 'small'
},
{
default: () => '已改派'
}
)
if (row.status == 2) {
return h(NTag, { type: 'info', size: 'small', bordered: false }, { default: () => '已改派' })
}
return '-'
}
},
{
align:'center',
align: 'center',
title: '操作',
key: 'action',
width: 180,
width: 196,
fixed: 'right',
render(row:any) {
const buttons:any = []
buttons.push(
h(
NButton,
{
size: 'small',
type: 'primary',
ghost:true,
onClick: () => {
handleReportRecords(row)
}
},
{ default: () => '汇报信息' }
)
)
buttons.push(
h(
NButton,
{
size: 'small',
type: 'primary',
ghost:true,
onClick: () => {
handleQualityRecords(row)
}
},
{ default: () => '质检信息' }
)
)
return buttons.length > 0 ? h(NSpace, {justify:'center'}, { default: () => buttons }) : '-'
render(row: any) {
return h('div', { class: 'action-cell' }, [
h(NButton, {
size: 'small',
quaternary: true,
onClick: () => handleReportRecords(row)
}, {
default: () => [
h(NIcon, { size: 16 }, { default: () => h(DocumentTextOutline) }),
' 汇报信息'
]
}),
h(NButton, {
size: 'small',
quaternary: true,
onClick: () => handleQualityRecords(row)
}, {
default: () => [
h(NIcon, { size: 16 }, { default: () => h(ShieldCheckmarkOutline) }),
' 质检信息'
]
})
])
}
}
@ -400,6 +386,33 @@ const AssingWorkDetailColums:DataTableColumns<AssingWorkDetail> = [
key:"num",
width:80
},
{
align:"center",
title:"暂停时间",
key:"pauseSeconds",
width:120,
render(row: any) {
return formatWorkSeconds(row.pauseSeconds)
}
},
{
align:"center",
title:"调车时间",
key:"shuntSeconds",
width:120,
render(row: any) {
return formatWorkSeconds(row.shuntSeconds)
}
},
{
align:"center",
title:"实际工时",
key:"actualWorkSeconds",
width:120,
render(row: any) {
return formatWorkSeconds(row.actualWorkSeconds)
}
},
{
align:"center",
title:"来源",
@ -707,4 +720,114 @@ defineExpose({
.pgClass {
font-weight: bold;
}
:deep(.assing-work-table .n-data-table-th) {
padding-top: 6px;
padding-bottom: 6px;
}
:deep(.assing-work-table .n-data-table-td) {
padding-top: 4px;
padding-bottom: 4px;
}
:deep(.route-code-link) {
color: #1f6feb;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-decoration: none;
}
:deep(.route-code-link:hover) {
text-decoration: underline;
}
:deep(.cell-inline) {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
:deep(.cell-main) {
color: #1f2328;
font-weight: 600;
flex-shrink: 0;
}
:deep(.cell-sub) {
color: #656d76;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.cell-full) {
display: inline-block;
white-space: nowrap;
overflow: visible;
word-break: keep-all;
}
:deep(.n-data-table-td.material-full-cell) {
overflow: visible;
white-space: nowrap;
}
:deep(.action-cell) {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: nowrap;
}
:deep(.flag-group) {
display: flex;
justify-content: center;
gap: 4px;
}
:deep(.flag-chip) {
display: inline-flex;
align-items: center;
padding: 0 6px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
:deep(.flag-chip.is-yes) {
color: #1a7f37;
background: #dafbe1;
}
:deep(.flag-chip.is-no) {
color: #656d76;
background: #f6f8fa;
}
:deep(.flag-chip.is-empty) {
color: #8c959f;
background: #f6f8fa;
}
:deep(.qty-text) {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #1f2328;
}
:deep(.time-text) {
font-variant-numeric: tabular-nums;
color: #424a53;
}
:deep(.hours-cell) {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
white-space: nowrap;
font-size: 12px;
color: #656d76;
}
:deep(.hours-item b) {
font-weight: 600;
color: #424a53;
font-variant-numeric: tabular-nums;
}
:deep(.hours-item.is-actual),
:deep(.hours-item.is-actual b) {
color: #1f6feb;
}
:deep(.hours-item.is-unit),
:deep(.hours-item.is-unit b) {
color: #0d9488;
}
</style>

View File

@ -1,46 +1,38 @@
<template>
<n-card>
<n-card style="margin: 12px 0px">
<n-grid :cols="2" style="margin: 10px">
<n-gi>
<span class="pgClass">物料名称</span>
<span>{{props.baseInfo.materialName}}</span>
</n-gi>
<n-gi>
<span class="pgClass">物料编号</span>
<span>{{props.baseInfo.materialCode}}</span>
</n-gi>
</n-grid>
<n-grid :cols="2" style="margin: 10px">
<n-gi>
<span class="pgClass">工序名称</span>
<span>{{props.baseInfo.processName}}</span>
</n-gi>
<n-gi>
<span class="pgClass">派工编号</span>
<span>{{props.baseInfo.assingCode}}</span>
</n-gi>
</n-grid>
</n-card>
<div class="info-panel">
<n-descriptions
:column="2"
bordered
size="small"
label-placement="left"
class="info-header"
>
<n-descriptions-item label="物料编码">{{ dash(props.baseInfo?.materialCode) }}</n-descriptions-item>
<n-descriptions-item label="物料名称">{{ dash(props.baseInfo?.materialName) }}</n-descriptions-item>
<n-descriptions-item label="工序名称">{{ dash(props.baseInfo?.processName) }}</n-descriptions-item>
<n-descriptions-item label="派工编号">{{ dash(props.baseInfo?.assingCode) }}</n-descriptions-item>
</n-descriptions>
<n-data-table
:columns="reportRecordsColums"
size="small"
:data="qualityTestingList"
remote
:scroll-x="600"
:columns="reportRecordsColums"
:data="qualityTestingList"
:loading="loading"
size="small"
striped
:single-line="true"
:row-key="(row: QualityTesting) => row.id ?? row.qcNo ?? ''"
/>
<div class="pagination-container" style="display: flex; justify-content: flex-end; margin-top: 12px">
<div class="pagination-container">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:page-sizes="[2,10, 20, 50, 100]"
show-size-picker
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:page-sizes="[10, 20, 50, 100]"
show-size-picker
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
>
<template #prefix>
{{ pagination.itemCount }}
@ -48,126 +40,172 @@
</n-pagination>
</div>
<!--质检详情弹窗-->
<n-modal v-model:show="showAssingWorkDetail" title="派工详情" style="width: 1000px" >
<n-card hoverable>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>明细列表</span>
<n-button text @click="detailIsCollapse = !detailIsCollapse">
<n-icon :component="detailIsCollapse ? ArrowDownOutline : ArrowUpOutline" />
</n-button>
</div>
</template>
<n-data-table
v-show="!detailIsCollapse"
:columns="detailColumns"
:data="detailTableData"
:row-key="(row:any) => row.id"
:scroll-x="700"
max-height="320"
:pagination="{ pageSize:4 }"
/>
</n-card>
</n-modal>
</n-card>
<div v-if="showDetail" class="detail-panel">
<div class="detail-head">
<span>质检明细<span v-if="currentQcNo" class="detail-no">{{ currentQcNo }}</span></span>
<n-button text size="small" @click="showDetail = false">收起</n-button>
</div>
<n-data-table
:columns="detailColumns"
:data="detailTableData"
size="small"
striped
:single-line="true"
:row-key="(row: any) => row.id"
:max-height="280"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {NCard, NDataTable, NGi, NTag, NGrid, DataTableColumns, NIcon, NButton} from "naive-ui";
import {onMounted, ref, h, reactive} from "vue";
import { NDataTable, NTag, NDescriptions, NDescriptionsItem, NPagination, NButton, type DataTableColumns } from 'naive-ui'
import { onMounted, ref, h, reactive } from 'vue'
import { dictDataApi } from '@/api/org'
import { QualityTesting, qualityTestingApi } from '@/api/qualityTesting.ts'
import type { QcResultDetail } from '@/api/qcResultDetail.ts'
import {QualityTesting,qualityTestingApi} from "@/api/qualityTesting.ts";
import {ArrowDownOutline, ArrowUpOutline} from "@vicons/ionicons5";
import type {QcResultDetail} from "@/api/qcResultDetail.ts";
const props = defineProps<{
assingId?: number
baseInfo?: Record<string, any>
}>()
function dash(value: unknown) {
if (value === 0) return '0'
if (value == null || value === '') return '-'
return String(value)
}
const props =defineProps(
{
assingId:Object,
baseInfo:Object
}
)
const pagination = reactive({
page: 1,
pageSize: 10,
itemCount: 0
})
const loading = ref(false)
const qualityTestingList = ref<QualityTesting[]>([])
const showDetail = ref(false)
const currentQcNo = ref('')
const detailTableData = ref<QcResultDetail[]>([])
//
const qcSourceTypeOptions = ref<{ label: string; value: any;class:any }[]>([])
const qcStatusOptions = ref<{ label: string; value: any;class:any }[]>([])
const qcSourceTypeOptions = ref<{ label: string; value: any; class: any }[]>([])
const qcStatusOptions = ref<{ label: string; value: any; class: any }[]>([])
const qcResultTypeOptions = ref<{ label: string; value: any; class: any }[]>([])
const qcActionStatusOptions = ref<{ label: string; value: any; class: any }[]>([])
//
const reportRecordsColums:DataTableColumns<QualityTesting> = [
{ title: '质检编号', key: 'qcNo',align: 'center',
render(row:any) {
const reportRecordsColums: DataTableColumns<QualityTesting> = [
{
title: '质检编号',
key: 'qcNo',
align: 'center',
render(row: any) {
return h(
'a',
{
class:"route-code-link",
href: 'javascript:;',
style:{
color: '#2080f0',
textDecoration: 'underline',
cursor: 'pointer',
},
onClick: (e: MouseEvent) => {
viewQualityResultDetail(row)
}
},
row.qcNo
'a',
{
class: 'route-code-link',
href: 'javascript:;',
onClick: () => viewQualityResultDetail(row)
},
row.qcNo || '-'
)
}
},
{ title: '报检总数', key: 'totalQty',align: 'center' },
{ title: '已检验数量', key: 'inspectQty',align: 'center' },
{ title: '质检状态', key: 'status',align: 'center',
{ title: '报检总数', key: 'totalQty', align: 'center', width: 100, render: (row) => dash(row.totalQty) },
{ title: '已检验数量', key: 'inspectQty', align: 'center', width: 110, render: (row) => dash(row.inspectQty) },
{
title: '质检状态',
key: 'status',
align: 'center',
width: 100,
render(row) {
const val = row.status
const opt = qcStatusOptions.value.find(o => o.value === val || String(o.value) === String(val))
if (!opt) return val ?? '-'
return h(NTag, { type: opt.class, size: 'small' }, { default: () => opt.label })
if (!opt) return dash(val)
return h(NTag, { type: opt.class, size: 'small', bordered: false }, { default: () => opt.label })
}
},
{ title: '检验完成时间', key: 'inspectTime',align: 'center' },
{ title: '检验完成时间', key: 'inspectTime', align: 'center', width: 170, render: (row) => dash(row.inspectTime) }
]
const detailColumns: DataTableColumns<QcResultDetail> = [
{
title: '判定类型',
key: 'resultType',
align: 'center',
width: 100,
render: (row) => {
const val = row.resultType
const opt = qcResultTypeOptions.value.find(o => o.value === val || String(o.value) === String(val))
if (!opt) return dash(val)
return h(NTag, { type: opt.class, size: 'small', bordered: false }, { default: () => opt.label })
}
},
{ title: '判定数量', key: 'qty', align: 'center', width: 90, render: (row) => dash(row.qty) },
{ title: '批次号', key: 'traceCode', align: 'center', render: (row) => dash(row.traceCode) },
{ title: '不良代码', key: 'defectCode', align: 'center', render: (row) => dash(row.defectCode) },
{ title: '后续动作', key: 'handleAction', align: 'center', render: (row) => dash(row.handleAction) },
{
title: '状态',
key: 'actionStatus',
align: 'center',
width: 100,
render: (row) => {
const val = row.actionStatus
const opt = qcActionStatusOptions.value.find(o => o.value === val || String(o.value) === String(val))
if (!opt) return dash(val)
return h(NTag, { type: opt.class, size: 'small', bordered: false }, { default: () => opt.label })
}
}
]
//
async function loadDictOptions() {
try {
const data = await dictDataApi.listByType('source_type')
qcSourceTypeOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue),class: d.listClass }))
}catch {}
qcSourceTypeOptions.value = data.map(d => ({
label: d.dictLabel,
value: Number(d.dictValue) || d.dictValue,
class: d.listClass
}))
} catch {}
try {
const data = await dictDataApi.listByType('qc_status')
qcStatusOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue),class: d.listClass }))
}catch {}
qcStatusOptions.value = data.map(d => ({
label: d.dictLabel,
value: Number(d.dictValue) || d.dictValue,
class: d.listClass
}))
} catch {}
try {
const data = await dictDataApi.listByType('qc_result_type')
qcResultTypeOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue),class: d.listClass }))
}catch {}
qcResultTypeOptions.value = data.map(d => ({
label: d.dictLabel,
value: Number(d.dictValue) || d.dictValue,
class: d.listClass
}))
} catch {}
try {
const data = await dictDataApi.listByType('qc_action_status')
qcActionStatusOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue),class: d.listClass }))
}catch {}
qcActionStatusOptions.value = data.map(d => ({
label: d.dictLabel,
value: Number(d.dictValue) || d.dictValue,
class: d.listClass
}))
} catch {}
}
const qualityTestingList = ref<QualityTesting[]>([])
async function loadQualityTestingList() {
const res = await qualityTestingApi.loadQualityTestingList({
assingId:props.assingId,
page: pagination.page,
loading.value = true
try {
const res = await qualityTestingApi.loadQualityTestingList({
assingId: props.assingId,
page: pagination.page,
pageSize: pagination.pageSize
})
qualityTestingList.value = res.list
pagination.itemCount = res.total
})
qualityTestingList.value = res.list ?? []
pagination.itemCount = res.total ?? 0
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
@ -181,69 +219,56 @@ function handlePageSizeChange(pageSize: number) {
loadQualityTestingList()
}
const showAssingWorkDetail = ref<Boolean>(false)
const detailIsCollapse = ref(false)
const detailColumns: DataTableColumns<QcResultDetail> = [
{
title: '判定类型',
key: 'resultType',
align: 'center',
width: 80,
render: (row) => {
const val = row.resultType
const opt = qcResultTypeOptions.value.find(o => o.value === val || String(o.value) === String(val))
if (!opt) return val ?? '-'
return h(NTag, { type: opt.class, size: 'small' }, { default: () => opt.label })
}
},
{
title: '判定数量',
key: 'qty',
align: 'center',
width: 80
},
{
title: '批次号',
key: 'traceCode',
width: 120,
align: 'center',
render: (row) => {
const val = row.traceCode
return val ?? '-'
}
},
{
title: '状态',
key: 'actionStatus',
width: 80,
align: 'center',
render: (row) => {
const val = row.actionStatus
const opt = qcActionStatusOptions.value.find(o => o.value === val || String(o.value) === String(val))
if (!opt) return val ?? '-'
return h(NTag, { type: opt.class, size: 'small' }, { default: () => opt.label })
}
}
]
const detailTableData = ref<Object[]>([])
const qcResultTypeOptions = ref<{ label: string; value: any;class:any }[]>([])
//
const qcActionStatusOptions = ref<{ label: string; value: any;class:any }[]>([])
function viewQualityResultDetail(row) {
showAssingWorkDetail.value = true
detailTableData.value = row.qcResultDetailList
function viewQualityResultDetail(row: any) {
showDetail.value = true
currentQcNo.value = row.qcNo || ''
detailTableData.value = row.qcResultDetailList ?? []
}
onMounted(()=>{
onMounted(() => {
loadDictOptions()
loadQualityTestingList()
})
</script>
<style scoped>
<style scoped>
.info-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-header {
margin-bottom: 0;
}
.pagination-container {
display: flex;
justify-content: flex-end;
}
.detail-panel {
padding: 12px;
border: 1px solid #eaeef2;
border-radius: 6px;
background: #f8fafc;
}
.detail-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-weight: 600;
color: #1f2328;
}
.detail-no {
margin-left: 8px;
color: #1f6feb;
font-variant-numeric: tabular-nums;
}
:deep(.route-code-link) {
color: #1f6feb;
font-weight: 600;
text-decoration: none;
}
:deep(.route-code-link:hover) {
text-decoration: underline;
}
</style>

View File

@ -1,71 +1,69 @@
<template>
<n-card>
<n-card style="margin: 12px 0px">
<n-grid :cols="2" style="margin: 10px">
<n-gi>
<span class="pgClass">物料名称</span>
<span>{{props.baseInfo.materialName}}</span>
</n-gi>
<n-gi>
<span class="pgClass">物料编号</span>
<span>{{props.baseInfo.materialCode}}</span>
</n-gi>
</n-grid>
<n-grid :cols="2" style="margin: 10px">
<n-gi>
<span class="pgClass">工序名称</span>
<span>{{props.baseInfo.processName}}</span>
</n-gi>
<n-gi>
<span class="pgClass">派工编号</span>
<span>{{props.baseInfo.assingCode}}</span>
</n-gi>
</n-grid>
</n-card>
<div class="info-panel">
<n-descriptions
:column="2"
bordered
size="small"
label-placement="left"
class="info-header"
>
<n-descriptions-item label="物料编码">{{ dash(props.baseInfo?.materialCode) }}</n-descriptions-item>
<n-descriptions-item label="物料名称">{{ dash(props.baseInfo?.materialName) }}</n-descriptions-item>
<n-descriptions-item label="工序名称">{{ dash(props.baseInfo?.processName) }}</n-descriptions-item>
<n-descriptions-item label="派工编号">{{ dash(props.baseInfo?.assingCode) }}</n-descriptions-item>
</n-descriptions>
<n-data-table
:columns="reportRecordsColums"
size="small"
:data="submitLogList"
remote
:scroll-x="600"
:columns="reportRecordsColums"
:data="submitLogList"
:loading="loading"
size="small"
striped
:single-line="true"
:scroll-x="1080"
:row-key="(row: SubmitLog) => row.id ?? row.createTime ?? ''"
/>
<div class="pagination-container" style="display: flex; justify-content: flex-end; margin-top: 12px">
<div class="pagination-container">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:page-sizes="[2,10, 20, 50, 100]"
show-size-picker
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:page-sizes="[10, 20, 50, 100]"
show-size-picker
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
>
<template #prefix>
{{ pagination.itemCount }}
</template>
</n-pagination>
</div>
</n-card>
</div>
</template>
<script setup lang="ts">
import { NCard, NDataTable, NGi,NTag, NGrid,DataTableColumns} from "naive-ui";
import {onMounted, ref, h, reactive} from "vue";
import { NDataTable, NTag, NDescriptions, NDescriptionsItem, NPagination, type DataTableColumns } from 'naive-ui'
import { onMounted, ref, h, reactive } from 'vue'
import { dictDataApi } from '@/api/org'
import { SubmitLog,submitLogApi} from '@/api/submitLog'
import { SubmitLog, submitLogApi } from '@/api/submitLog'
import { formatWorkSeconds } from '@/utils/tool'
const props =defineProps(
{
assingId:Number,
baseInfo:Object
}
)
const props = defineProps<{
assingId?: number
baseInfo?: Record<string, any>
}>()
//
const submitStatusOptions = ref<{ label: string; value: any ;class:any}[]>([])
const reportTypeOptions = ref<{ label: string; value: any ;class:any}[]>([])
function dash(value: unknown) {
if (value === 0) return '0'
if (value == null || value === '') return '-'
return String(value)
}
const submitStatusOptions = ref<{ label: string; value: any; class: any }[]>([])
const loading = ref(false)
const submitLogList = ref<SubmitLog[]>([])
const pagination = reactive({
page: 1,
@ -73,46 +71,71 @@ const pagination = reactive({
itemCount: 0
})
//
const reportRecordsColums:DataTableColumns<SubmitLog> = [
{ title: '汇报数量', key: 'quantity',align: 'center'},
{ title: '合格数量', key: 'qualifiedQty',align: 'center'},
{ title: '报废数量', key: 'scrapeQty',align: 'center'},
{ title: '汇报人', key: 'userName',align: 'center' },
{ title: '状态', key: 'submitStatus',align: 'center',
render: (row) => {
const reportRecordsColums: DataTableColumns<SubmitLog> = [
{ title: '汇报数量', key: 'quantity', align: 'center', width: 90, render: (row) => dash(row.quantity) },
{ title: '合格数量', key: 'qualifiedQty', align: 'center', width: 90, render: (row: any) => dash(row.qualifiedQty) },
{ title: '报废数量', key: 'scrapQty', align: 'center', width: 90, render: (row: any) => dash(row.scrapQty) },
{
title: '暂停时间',
key: 'pauseSeconds',
align: 'center',
width: 120,
render: (row: any) => formatWorkSeconds(row.pauseSeconds)
},
{
title: '调车时间',
key: 'shuntSeconds',
align: 'center',
width: 120,
render: (row: any) => formatWorkSeconds(row.shuntSeconds)
},
{
title: '实际工时',
key: 'actualWorkSeconds',
align: 'center',
width: 120,
render: (row: any) => h('span', { class: 'hours-actual' }, formatWorkSeconds(row.actualWorkSeconds))
},
{ title: '汇报人', key: 'userName', align: 'center', width: 100, render: (row: any) => dash(row.userName) },
{
title: '状态',
key: 'submitStatus',
align: 'center',
width: 90,
render: (row: any) => {
const val = row.submitStatus
const opt = submitStatusOptions.value.find(o => o.value === val || String(o.value) === String(val))
if (!opt) return val ?? '-'
return h(NTag, { type: opt.class, size: 'small' }, { default: () => opt.label })
if (!opt) return dash(val)
return h(NTag, { type: opt.class, size: 'small', bordered: false }, { default: () => opt.label })
}
},
{ title: '汇报时间', key: 'createTime',align: 'center' },
{ title: '汇报时间', key: 'createTime', align: 'center', width: 160, render: (row) => dash(row.createTime) }
]
//
async function loadDictOptions() {
try {
const data = await dictDataApi.listByType('submit_status')
submitStatusOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue),class:d.listClass }))
}catch {}
try {
const data = await dictDataApi.listByType('qc_result_type')
reportTypeOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue),class:d.listClass }))
}catch {}
submitStatusOptions.value = data.map(d => ({
label: d.dictLabel,
value: Number(d.dictValue) || d.dictValue,
class: d.listClass
}))
} catch {}
}
//
const submitLogList = ref<SubmitLog[]>([])
async function load() {
const res = await submitLogApi.loadSumbitLog({
assingId:props.assingId,
page: pagination.page,
pageSize: pagination.pageSize
})
submitLogList.value = res.list
pagination.itemCount = res.total
loading.value = true
try {
const res = await submitLogApi.loadSumbitLog({
assingId: props.assingId ?? 0,
page: pagination.page,
pageSize: pagination.pageSize
})
submitLogList.value = res.list ?? []
pagination.itemCount = res.total ?? 0
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
@ -126,11 +149,28 @@ function handlePageSizeChange(pageSize: number) {
load()
}
onMounted(()=>{
onMounted(() => {
loadDictOptions()
load()
})
</script>
<style scoped>
<style scoped>
.info-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-header {
margin-bottom: 0;
}
.pagination-container {
display: flex;
justify-content: flex-end;
}
:deep(.hours-actual) {
color: #1f6feb;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
</style>

View File

@ -16,7 +16,7 @@ export default defineConfig({
emptyOutDir: true
},
server: {
//host:'192.168.12.4', //true,
host: true, // 监听 0.0.0.0,避免本机 IP 变化导致 EADDRNOTAVAIL
port: 3000,
proxy: {
'/api': {