You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

441 lines
14 KiB

2 months ago
  1. <template>
  2. <div class="mod-config">
  3. <el-card class="overview-card" shadow="never">
  4. <div slot="header" class="card-title-row">
  5. <div>
  6. <div class="page-title">生产追溯与运营看板</div>
  7. <div class="page-desc">面向批次任务单挂具物料的全链路追溯与关键指标监控</div>
  8. </div>
  9. <el-button plain class="search-btn" :loading="reportLoading" @click="loadReport">刷新报表</el-button>
  10. </div>
  11. <div class="metric-grid">
  12. <div class="metric-item">
  13. <div class="metric-label">任务总数</div>
  14. <div class="metric-value">{{ metricData.totalJobs }}</div>
  15. </div>
  16. <div class="metric-item">
  17. <div class="metric-label">完工任务</div>
  18. <div class="metric-value">{{ metricData.completedJobs }}</div>
  19. </div>
  20. <div class="metric-item">
  21. <div class="metric-label">在制任务</div>
  22. <div class="metric-value">{{ metricData.processingJobs }}</div>
  23. </div>
  24. <div class="metric-item">
  25. <div class="metric-label">任务完工率</div>
  26. <div class="metric-value">{{ formatRate(report.jobCompletionRate) }}</div>
  27. </div>
  28. <div class="metric-item">
  29. <div class="metric-label">过站通过数</div>
  30. <div class="metric-value">{{ metricData.stationPassCount }}</div>
  31. </div>
  32. <div class="metric-item">
  33. <div class="metric-label">过站拦截数</div>
  34. <div class="metric-value">{{ metricData.stationInterceptCount }}</div>
  35. </div>
  36. <div class="metric-item">
  37. <div class="metric-label">过站拦截率</div>
  38. <div class="metric-value">{{ formatRate(report.interceptRate) }}</div>
  39. </div>
  40. <div class="metric-item">
  41. <div class="metric-label">手工录入数</div>
  42. <div class="metric-value">{{ metricData.manualRecordCount }}</div>
  43. </div>
  44. </div>
  45. <div class="report-time">报表更新时间{{ reportRefreshTime || '-' }}</div>
  46. </el-card>
  47. <el-card style="margin-top: 12px;">
  48. <div slot="header" class="card-title-row">
  49. <span>追溯查询工作台</span>
  50. <div>
  51. <el-button plain class="reset-btn" @click="resetTraceQuery">重置条件</el-button>
  52. <el-button plain class="search-btn" :loading="traceLoading" @click="loadTrace">查询</el-button>
  53. </div>
  54. </div>
  55. <el-form :inline="true" label-position="top" class="query-form">
  56. <el-form-item label="批次编码">
  57. <el-input v-model.trim="traceQueryForm.batchCode" clearable placeholder="例如 BATCH-20260428-01" style="width: 220px" @keyup.enter.native="loadTrace" />
  58. </el-form-item>
  59. <el-form-item label="任务单号">
  60. <el-input v-model.trim="traceQueryForm.jobCode" clearable placeholder="例如 JOB-20260428-01" style="width: 220px" @keyup.enter.native="loadTrace" />
  61. </el-form-item>
  62. <el-form-item label="挂具编码">
  63. <el-input v-model.trim="traceQueryForm.rackCode" clearable placeholder="例如 RACK-01" style="width: 180px" @keyup.enter.native="loadTrace" />
  64. </el-form-item>
  65. <el-form-item label="物料编码">
  66. <el-input v-model.trim="traceQueryForm.partNo" clearable placeholder="例如 PART-001" style="width: 180px" @keyup.enter.native="loadTrace" />
  67. </el-form-item>
  68. </el-form>
  69. <el-tabs v-model="traceTab" type="border-card">
  70. <el-tab-pane label="追溯摘要" name="summary">
  71. <el-descriptions :column="4" border>
  72. <el-descriptions-item label="批次">{{ traceSummary.batchCode || '-' }}</el-descriptions-item>
  73. <el-descriptions-item label="任务单">{{ traceSummary.jobCode || '-' }}</el-descriptions-item>
  74. <el-descriptions-item label="挂具">{{ traceSummary.rackCode || '-' }}</el-descriptions-item>
  75. <el-descriptions-item label="物料">{{ traceSummary.partNo || '-' }}</el-descriptions-item>
  76. <el-descriptions-item label="当前状态">{{ traceSummary.status || '-' }}</el-descriptions-item>
  77. <el-descriptions-item label="通过工序数">{{ traceSummary.passCount }}</el-descriptions-item>
  78. <el-descriptions-item label="拦截次数">{{ traceSummary.interceptCount }}</el-descriptions-item>
  79. <el-descriptions-item label="手工补录次数">{{ traceSummary.manualCount }}</el-descriptions-item>
  80. </el-descriptions>
  81. </el-tab-pane>
  82. <el-tab-pane label="过站记录" name="station">
  83. <el-table class="data-table" :data="stationRows" border height="260" style="width: 100%">
  84. <el-table-column type="index" width="50" />
  85. <el-table-column prop="time" label="时间" min-width="170" />
  86. <el-table-column prop="stepCode" label="工序编码" width="120" />
  87. <el-table-column prop="stationId" label="站点/池子" width="140" />
  88. <el-table-column prop="rackCode" label="挂具" width="120" />
  89. <el-table-column prop="result" label="结果" width="100" />
  90. <el-table-column prop="message" label="备注" min-width="180" show-overflow-tooltip />
  91. </el-table>
  92. </el-tab-pane>
  93. <el-tab-pane label="手工录入记录" name="manual">
  94. <el-table class="data-table" :data="manualRows" border height="260" style="width: 100%">
  95. <el-table-column type="index" width="50" />
  96. <el-table-column prop="recordType" label="类型" width="110" />
  97. <el-table-column prop="docNo" label="单号" min-width="150" />
  98. <el-table-column prop="qty" label="数量" width="100" />
  99. <el-table-column prop="operatorName" label="录入人" width="120" />
  100. <el-table-column prop="reviewerName" label="复核人" width="120" />
  101. <el-table-column prop="recordTime" label="录入时间" min-width="170" />
  102. </el-table>
  103. </el-tab-pane>
  104. <el-tab-pane label="原始JSON" name="raw">
  105. <pre class="trace-box">{{ prettyTraceJson }}</pre>
  106. </el-tab-pane>
  107. </el-tabs>
  108. </el-card>
  109. </div>
  110. </template>
  111. <script>
  112. import { traceQuery, reportOverview } from '@/api/rack/closedLoop'
  113. export default {
  114. data () {
  115. return {
  116. traceQueryForm: { batchCode: '', jobCode: '', rackCode: '', partNo: '' },
  117. traceLoading: false,
  118. reportLoading: false,
  119. traceTab: 'summary',
  120. reportRefreshTime: '',
  121. traceResult: {},
  122. report: {}
  123. }
  124. },
  125. computed: {
  126. metricData () {
  127. const totalJobs = Number(this.report.totalJobs) || 0
  128. const completedJobs = Number(this.report.completedJobs) || 0
  129. return {
  130. totalJobs,
  131. completedJobs,
  132. processingJobs: Math.max(totalJobs - completedJobs, 0),
  133. stationPassCount: Number(this.report.stationPassCount) || 0,
  134. stationInterceptCount: Number(this.report.stationInterceptCount) || 0,
  135. manualRecordCount: Number(this.report.manualRecordCount) || 0
  136. }
  137. },
  138. traceSummary () {
  139. return {
  140. batchCode: this.pickFirstValue(this.traceResult, ['batchCode', 'batchNo', 'inboundBatchCode']),
  141. jobCode: this.pickFirstValue(this.traceResult, ['jobCode', 'taskCode']),
  142. rackCode: this.pickFirstValue(this.traceResult, ['rackCode', 'currentRackCode']),
  143. partNo: this.pickFirstValue(this.traceResult, ['partNo', 'materialCode']),
  144. status: this.pickFirstValue(this.traceResult, ['status', 'jobStatus', 'traceStatus']),
  145. passCount: this.safeNumber(this.pickFirstValue(this.traceResult, ['passCount', 'stationPassCount'])),
  146. interceptCount: this.safeNumber(this.pickFirstValue(this.traceResult, ['interceptCount', 'stationInterceptCount'])),
  147. manualCount: this.safeNumber(this.pickFirstValue(this.traceResult, ['manualCount', 'manualRecordCount']))
  148. }
  149. },
  150. stationRows () {
  151. const source = this.pickFirstArray(this.traceResult, [
  152. 'stationRecords',
  153. 'stationPassLogs',
  154. 'passLogs',
  155. 'traceRecords',
  156. 'events'
  157. ])
  158. return source.map(item => ({
  159. time: this.formatDateTime(this.pickFirstValue(item, ['eventTime', 'passTime', 'recordTime', 'createTime', 'time'])),
  160. stepCode: this.pickFirstValue(item, ['stepCode', 'processCode', 'stepNo']),
  161. stationId: this.pickFirstValue(item, ['stationId', 'poolCode', 'stationCode']),
  162. rackCode: this.pickFirstValue(item, ['rackCode']),
  163. result: this.pickFirstValue(item, ['result', 'eventType', 'status']) || '-',
  164. message: this.pickFirstValue(item, ['msg', 'message', 'remark']) || '-'
  165. }))
  166. },
  167. manualRows () {
  168. const source = this.pickFirstArray(this.traceResult, [
  169. 'manualRecords',
  170. 'manualRecordList',
  171. 'manualRows',
  172. 'manualList'
  173. ])
  174. return source.map(item => ({
  175. recordType: item.recordType || '-',
  176. docNo: item.docNo || '-',
  177. qty: this.safeNumber(item.qty),
  178. operatorName: item.operatorName || '-',
  179. reviewerName: item.reviewerName || '-',
  180. recordTime: this.formatDateTime(item.recordTime)
  181. }))
  182. },
  183. prettyTraceJson () {
  184. return JSON.stringify(this.traceResult || {}, null, 2)
  185. }
  186. },
  187. mounted () {
  188. this.loadReport()
  189. },
  190. methods: {
  191. resetTraceQuery () {
  192. this.traceQueryForm = { batchCode: '', jobCode: '', rackCode: '', partNo: '' }
  193. this.traceResult = {}
  194. this.traceTab = 'summary'
  195. },
  196. async loadTrace () {
  197. const hasCondition = Object.keys(this.traceQueryForm).some(key => String(this.traceQueryForm[key] || '').trim())
  198. if (!hasCondition) {
  199. this.$message.warning('请至少输入一个追溯条件')
  200. return
  201. }
  202. this.traceLoading = true
  203. try {
  204. const { data } = await traceQuery(this.traceQueryForm)
  205. this.traceResult = data.result || {}
  206. if (!Object.keys(this.traceResult).length) {
  207. this.$message.warning('未查询到追溯结果')
  208. }
  209. } finally {
  210. this.traceLoading = false
  211. }
  212. },
  213. async loadReport () {
  214. this.reportLoading = true
  215. try {
  216. const { data } = await reportOverview()
  217. this.report = data.result || {}
  218. this.reportRefreshTime = this.formatDateTime(Date.now())
  219. } finally {
  220. this.reportLoading = false
  221. }
  222. },
  223. formatRate (value) {
  224. if (value === null || value === undefined) return '0%'
  225. return `${(Number(value) * 100).toFixed(2)}%`
  226. },
  227. safeNumber (val) {
  228. return Number(val) || 0
  229. },
  230. pickFirstValue (obj, fields) {
  231. if (!obj || typeof obj !== 'object') {
  232. return ''
  233. }
  234. for (let i = 0; i < fields.length; i++) {
  235. const key = fields[i]
  236. if (obj[key] !== undefined && obj[key] !== null && obj[key] !== '') {
  237. return obj[key]
  238. }
  239. }
  240. return ''
  241. },
  242. pickFirstArray (obj, fields) {
  243. for (let i = 0; i < fields.length; i++) {
  244. const value = this.pickFirstValue(obj, [fields[i]])
  245. if (Array.isArray(value)) {
  246. return value
  247. }
  248. }
  249. return []
  250. },
  251. formatDateTime (v) {
  252. if (!v) return '-'
  253. let date = null
  254. if (typeof v === 'number') {
  255. date = new Date(v)
  256. } else if (typeof v === 'string') {
  257. const trimmed = v.trim()
  258. if (/^\d+$/.test(trimmed)) {
  259. date = new Date(Number(trimmed))
  260. } else {
  261. date = new Date(trimmed)
  262. }
  263. } else if (v instanceof Date) {
  264. date = v
  265. } else {
  266. date = new Date(v)
  267. }
  268. if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
  269. return String(v).replace('T', ' ')
  270. }
  271. const y = date.getFullYear()
  272. const m = String(date.getMonth() + 1).padStart(2, '0')
  273. const d = String(date.getDate()).padStart(2, '0')
  274. const hh = String(date.getHours()).padStart(2, '0')
  275. const mm = String(date.getMinutes()).padStart(2, '0')
  276. const ss = String(date.getSeconds()).padStart(2, '0')
  277. return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
  278. }
  279. }
  280. }
  281. </script>
  282. <style scoped>
  283. .overview-card {
  284. margin-bottom: 12px;
  285. }
  286. .card-title-row {
  287. display: flex;
  288. align-items: center;
  289. justify-content: space-between;
  290. }
  291. .page-title {
  292. font-size: 16px;
  293. font-weight: 600;
  294. color: #303133;
  295. }
  296. .page-desc {
  297. margin-top: 4px;
  298. font-size: 12px;
  299. color: #909399;
  300. }
  301. .metric-grid {
  302. display: grid;
  303. grid-template-columns: repeat(4, minmax(130px, 1fr));
  304. gap: 10px;
  305. }
  306. .metric-item {
  307. border: 1px solid #ebeef5;
  308. border-radius: 6px;
  309. background: #fff;
  310. padding: 10px;
  311. }
  312. .metric-label {
  313. font-size: 12px;
  314. color: #909399;
  315. }
  316. .metric-value {
  317. margin-top: 6px;
  318. font-size: 20px;
  319. font-weight: 600;
  320. color: #303133;
  321. }
  322. .report-time {
  323. margin-top: 10px;
  324. color: #909399;
  325. font-size: 12px;
  326. }
  327. .data-table {
  328. background-color: #fff;
  329. border-radius: 4px;
  330. }
  331. .data-table >>> .el-table__header-wrapper th,
  332. .data-table >>> .el-table__fixed-header-wrapper th {
  333. background-color: #f5f7fa !important;
  334. color: #333;
  335. font-weight: 600;
  336. border-color: #ebeef5;
  337. padding: 8px 0;
  338. }
  339. .data-table >>> .el-table__header-wrapper .cell,
  340. .data-table >>> .el-table__fixed-header-wrapper .cell,
  341. .data-table >>> .el-table__body-wrapper .cell,
  342. .data-table >>> .el-table__fixed-body-wrapper .cell {
  343. padding: 0 10px;
  344. overflow: hidden;
  345. text-overflow: ellipsis;
  346. white-space: nowrap;
  347. font-size: 13px !important;
  348. }
  349. .query-form {
  350. background-color: #fff;
  351. padding: 5px 0 0;
  352. border-radius: 4px;
  353. margin-bottom: 12px;
  354. }
  355. .query-form >>> .el-form-item__label {
  356. color: #333;
  357. font-size: 13px;
  358. padding-bottom: 5px;
  359. }
  360. .query-form >>> .el-input__inner {
  361. height: 32px;
  362. line-height: 32px;
  363. border-radius: 4px;
  364. font-size: 13px;
  365. }
  366. .query-form >>> .el-button {
  367. height: 32px;
  368. padding: 0 15px;
  369. font-size: 13px;
  370. border-radius: 4px;
  371. }
  372. .search-btn {
  373. background-color: #ecf5ff;
  374. border-color: #b3d8ff;
  375. color: #409eff;
  376. }
  377. .search-btn:hover {
  378. background-color: #409eff;
  379. border-color: #409eff;
  380. color: #fff;
  381. }
  382. .reset-btn {
  383. background-color: #f5f7fa;
  384. border-color: #d3d4d6;
  385. color: #606266;
  386. }
  387. .reset-btn:hover {
  388. background-color: #909399;
  389. border-color: #909399;
  390. color: #fff;
  391. }
  392. .add-btn {
  393. background-color: #f0f9eb;
  394. border-color: #c2e7b0;
  395. color: #67c23a;
  396. }
  397. .add-btn:hover {
  398. background-color: #67c23a;
  399. border-color: #67c23a;
  400. color: #fff;
  401. }
  402. .trace-box {
  403. margin: 0;
  404. max-height: 300px;
  405. overflow: auto;
  406. background: #f7f7f7;
  407. border: 1px solid #ebeef5;
  408. border-radius: 4px;
  409. padding: 10px;
  410. }
  411. </style>