From 7e9155fef11146dcaa720face772e062044fa747 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 31 Aug 2026 15:18:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-28-qc-third-party-company-card.md | 611 ++++++++++++++++++ ...8-28-qc-third-party-company-card-design.md | 160 +++++ .../src/utils/qc-company-task-group.test.ts | 137 ++++ web-admin/src/utils/qc-company-task-group.ts | 106 +++ .../SampleReceptionDialog.vue | 1 - .../QcCompanyTaskResultDialog.vue | 542 ++++++++++++++++ .../qc-work-order/QcWorkOrderTaskSubList.vue | 265 ++++++-- 7 files changed, 1754 insertions(+), 68 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-28-qc-third-party-company-card.md create mode 100644 docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md create mode 100644 web-admin/src/utils/qc-company-task-group.test.ts create mode 100644 web-admin/src/utils/qc-company-task-group.ts create mode 100644 web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue diff --git a/docs/superpowers/plans/2026-08-28-qc-third-party-company-card.md b/docs/superpowers/plans/2026-08-28-qc-third-party-company-card.md new file mode 100644 index 00000000..1439a20d --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-qc-third-party-company-card.md @@ -0,0 +1,611 @@ +# 质检第三方按公司聚合卡片 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** 质检工单 / 我的质检任务批次列表中,自检仍一项目一卡;第三方按「批次内公司」聚合成公司卡,弹窗内分项填参数/状态/结论,备注与检测材料一份并按可写集合同步写入。 + +**Architecture:** 纯前端改造。新增 `qc-company-task-group` 工具做分组与共享字段选取;新增 `QcCompanyTaskResultDialog` 做公司弹窗;在 `QcWorkOrderTaskSubList` 将任务拆成自检卡 + 公司卡并接线。保存循环调用现有 `fetchCompleteTaskWithResult`,不改后端表结构。 + +**Tech Stack:** Vue 3 + TypeScript + Element Plus(web-admin);单测用 `node:test` + `pnpm exec tsx --test`。 + +**Spec:** `docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md` +**Demo:** `.superpowers/brainstorm/80309-1787910046/content/001-qc-company-card-demo.html` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| Create: `web-admin/src/utils/qc-company-task-group.ts` | 第三方按公司分组、进度汇总、共享备注/附件选取 | +| Create: `web-admin/src/utils/qc-company-task-group.test.ts` | 上述纯函数单测 | +| Create: `web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue` | 公司维度填写/查看弹窗 | +| Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue` | 列表渲染拆分;打开公司弹窗;自检逻辑不动 | +| 不改 | 后端 API / DDL;配置检测弹窗;附件 ZIP 目录 | + +--- + +### Task 1: 分组与共享字段工具(TDD) + +**Files:** +- Create: `web-admin/src/utils/qc-company-task-group.ts` +- Create: `web-admin/src/utils/qc-company-task-group.test.ts` + +- [x] **Step 1: 写失败单测** + +```ts +// web-admin/src/utils/qc-company-task-group.test.ts +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { QcTask } from '@/api/qc-task' +import { + buildTaskListDisplayItems, + pickSharedRemarkAndAttachments, + companyGroupProgress +} from './qc-company-task-group' + +function task(partial: Partial & Pick): QcTask { + return { + taskNo: 'T1', + itemCode: 'I', + itemName: 'Item', + taskStatus: 'dzx', + workOrderId: 'wo1', + ...partial + } as QcTask +} + +describe('buildTaskListDisplayItems', () => { + it('keeps lab tasks as single cards and groups third-party by companyId', () => { + const tasks = [ + task({ id: 'l1', taskType: 'sysz', itemName: '无菌' }), + task({ + id: 't1', + taskType: 'dsfjc', + companyId: 'c1', + companyName: '金域', + itemName: '乙肝' + }), + task({ + id: 't2', + taskType: 'dsfjc', + companyId: 'c1', + companyName: '金域', + itemName: '丙肝' + }), + task({ + id: 't3', + taskType: 'dsfjc', + companyId: 'c2', + companyName: '迪安', + itemName: 'HIV' + }) + ] + const items = buildTaskListDisplayItems(tasks) + assert.equal(items.length, 3) + assert.equal(items[0].kind, 'lab') + assert.equal(items[0].kind === 'lab' && items[0].task.id, 'l1') + assert.equal(items[1].kind, 'company') + if (items[1].kind === 'company') { + assert.equal(items[1].companyId, 'c1') + assert.equal(items[1].tasks.length, 2) + } + assert.equal(items[2].kind, 'company') + if (items[2].kind === 'company') { + assert.equal(items[2].companyId, 'c2') + } + }) + + it('falls back to single lab-like card when third-party has empty companyId', () => { + const tasks = [ + task({ id: 't0', taskType: 'dsfjc', companyId: '', companyName: '', itemName: '孤儿' }) + ] + const items = buildTaskListDisplayItems(tasks) + assert.equal(items.length, 1) + assert.equal(items[0].kind, 'orphan-third') + }) +}) + +describe('companyGroupProgress', () => { + it('counts ywc vs total', () => { + const p = companyGroupProgress([ + task({ id: 'a', taskType: 'dsfjc', taskStatus: 'ywc' }), + task({ id: 'b', taskType: 'dsfjc', taskStatus: 'dzx' }) + ]) + assert.deepEqual(p, { completed: 1, total: 2 }) + }) +}) + +describe('pickSharedRemarkAndAttachments', () => { + it('picks latest non-empty remark and attachments by updateTime', () => { + const picked = pickSharedRemarkAndAttachments([ + task({ + id: 'a', + taskType: 'dsfjc', + remark: '旧备注', + thirdPartyAttachments: 'http://a.pdf', + updateTime: '2026-08-01 10:00:00', + attachmentFiles: [{ fileUrl: 'http://a.pdf', fileName: 'a.pdf' }] + }), + task({ + id: 'b', + taskType: 'dsfjc', + remark: '新备注', + thirdPartyAttachments: '', + updateTime: '2026-08-28 12:00:00', + attachmentFiles: [] + }) + ]) + assert.equal(picked.remark, '新备注') + assert.equal(picked.thirdPartyAttachments, 'http://a.pdf') + assert.equal(picked.attachmentFiles?.length, 1) + }) +}) +``` + +- [x] **Step 2: 跑测确认失败** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/qc-company-task-group.test.ts +``` + +Expected: FAIL(模块不存在) + +- [x] **Step 3: 实现工具** + +```ts +// web-admin/src/utils/qc-company-task-group.ts +import type { QcTask } from '@/api/qc-task' + +export type QcTaskListLabItem = { kind: 'lab'; task: QcTask } +export type QcTaskListOrphanThirdItem = { kind: 'orphan-third'; task: QcTask } +export type QcTaskListCompanyItem = { + kind: 'company' + companyId: string + companyName: string + tasks: QcTask[] +} +export type QcTaskListDisplayItem = + | QcTaskListLabItem + | QcTaskListOrphanThirdItem + | QcTaskListCompanyItem + +function trimId(v?: string) { + return (v || '').trim() +} + +/** 自检卡在前(原序);有 companyId 的第三方按首次出现公司顺序分组;无 companyId 降级为 orphan-third */ +export function buildTaskListDisplayItems(tasks: QcTask[]): QcTaskListDisplayItem[] { + const lab: QcTaskListDisplayItem[] = [] + const orphans: QcTaskListDisplayItem[] = [] + const companyOrder: string[] = [] + const companyMap = new Map() + + for (const t of tasks || []) { + if ((t.taskType || '').trim() === 'sysz') { + lab.push({ kind: 'lab', task: t }) + continue + } + if ((t.taskType || '').trim() !== 'dsfjc') { + lab.push({ kind: 'lab', task: t }) + continue + } + const cid = trimId(t.companyId) + if (!cid) { + orphans.push({ kind: 'orphan-third', task: t }) + continue + } + let g = companyMap.get(cid) + if (!g) { + g = { + kind: 'company', + companyId: cid, + companyName: (t.companyName || '').trim() || '第三方公司', + tasks: [] + } + companyMap.set(cid, g) + companyOrder.push(cid) + } + g.tasks.push(t) + } + + return [ + ...lab, + ...companyOrder.map((id) => companyMap.get(id)!), + ...orphans + ] +} + +export function companyGroupProgress(tasks: QcTask[]) { + const list = tasks || [] + const completed = list.filter((t) => (t.taskStatus || '').trim() === 'ywc').length + return { completed, total: list.length } +} + +function timeMs(v?: string) { + if (!v) return 0 + const n = Date.parse(String(v).replace(/-/g, '/')) + return Number.isFinite(n) ? n : 0 +} + +export function pickSharedRemarkAndAttachments(tasks: QcTask[]): { + remark: string + thirdPartyAttachments: string + attachmentFiles: NonNullable +} { + const list = [...(tasks || [])].sort((a, b) => timeMs(b.updateTime) - timeMs(a.updateTime)) + let remark = '' + let thirdPartyAttachments = '' + let attachmentFiles: NonNullable = [] + for (const t of list) { + if (!remark && (t.remark || '').trim()) remark = (t.remark || '').trim() + const urls = (t.thirdPartyAttachments || '').trim() + const files = t.attachmentFiles || [] + if (!thirdPartyAttachments && (urls || files.length)) { + thirdPartyAttachments = urls + attachmentFiles = files.length ? [...files] : [] + } + if (remark && (thirdPartyAttachments || attachmentFiles.length)) break + } + return { remark, thirdPartyAttachments, attachmentFiles } +} + +export function uniqueAssigneeNames(tasks: QcTask[]): string { + const names: string[] = [] + const seen = new Set() + for (const t of tasks || []) { + const raw = (t.assigneeUserNames || '').trim() + if (!raw) continue + for (const part of raw.split(/[,,、]/)) { + const n = part.trim() + if (!n || seen.has(n)) continue + seen.add(n) + names.push(n) + } + } + return names.join('、') || '—' +} +``` + +- [x] **Step 4: 跑测确认通过** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/qc-company-task-group.test.ts +``` + +Expected: PASS + +- [x] **Step 5: Commit**(仅当用户要求提交时执行) + +```bash +git add web-admin/src/utils/qc-company-task-group.ts web-admin/src/utils/qc-company-task-group.test.ts +git commit -m "$(cat <<'EOF' +feat(qc): add third-party task grouping helpers by company + +EOF +)" +``` + +--- + +### Task 2: 公司结果弹窗组件 + +**Files:** +- Create: `web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue` + +- [x] **Step 1: 实现弹窗骨架与 props** + +对齐现网 `QcWorkOrderTaskSubList` 单任务填写弹窗: + +- `dialog-form-layout` + `align-center` + `append-to-body` + `modal-class="protocol-dialog-mask"` +- Props: + - `visible`(v-model) + - `companyName: string` + - `tasks: QcTask[]`(可写/可见集合) + - `readonly?: boolean` + - `operateScope: QcTaskOperateScope` +- Emits:`update:visible`、`saved` + +打开时: + +1. 对每个 `task` 并行 `fetchQcTaskDetail` + `fetchQcTaskResults` +2. 本地状态:`itemStates: Array<{ taskId, itemName, params: QcTaskResultItem[], saveStatus, conclusion }>` +3. 共享:`remark`、`attachments`(`apiFilesToFinanceItems` + `pickSharedRemarkAndAttachments`) + +模板结构(要点): + +```vue + + + +
+
+ {{ item.itemName }} + + {{ taskStatusLabel(item.saveStatus) }} + +
+
检测参数
+
+
检测结论
+
+ + + 待执行 + 已完成 + + + + + 待结论 + 通过 + 不通过 + + +
+
+
+
备注与检测材料
+ + + + +
+
+
+ +
+``` + +注意:`ProtocolFinanceConfirmFields` 若无 `disabled` prop,只读时用 `v-if` 换成只读附件列表(`ProtocolAttachmentReadonly`),与现网查看态一致即可。 + +- [x] **Step 2: 实现 submit** + +校验顺序: + +1. 附件上传中 → warning +2. `anyCompleted` 且无有效附件 →「请上传检测材料」 +3. 任一 `saveStatus === 'ywc'` 且结论非 tg/btg →「请选择检测结论」(可带项目名) + +然后顺序(或 `for...of`)调用: + +```ts +await fetchCompleteTaskWithResult( + item.taskId, + { + conclusion: item.conclusion, + results: item.params, + remark, + thirdPartyAttachments: financeAttachmentsToUrlString(attachments), + taskStatus: item.saveStatus + }, + operateScope +) +``` + +收集失败项:`failures: string[]`;若有失败 `ElMessage.error` 列出项目名;若全部成功 `ElMessage.success`、emit `saved`、关弹窗。部分成功也 emit `saved` 以便列表刷新。 + +复用标签 helper:从 `@/utils/qc-labels` 或 TaskSubList 现有 `taskStatusLabel` / `taskStatusTagType` / `parseQcParamOptions` 导入,**不要复制粘贴一大段标签映射**;若现函数未导出,把纯函数抽到 `qc-labels` 或本 dialog 旁小 util。 + +- [x] **Step 3: 本地目视** + +`pnpm dev`,临时在任一页挂载组件用假数据打开(或等 Task 3 接线后验)。本任务以组件可编译、逻辑自洽为准。 + +- [x] **Step 4: Commit**(仅当用户要求提交时) + +```bash +git add web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue +git commit -m "$(cat <<'EOF' +feat(qc): add company-level QC result dialog for third-party tasks + +EOF +)" +``` + +--- + +### Task 3: 列表接线(QcWorkOrderTaskSubList) + +**Files:** +- Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue` + +- [x] **Step 1: 计算 displayItems** + +```ts +import { + buildTaskListDisplayItems, + companyGroupProgress, + uniqueAssigneeNames, + type QcTaskListCompanyItem +} from '@/utils/qc-company-task-group' +import QcCompanyTaskResultDialog from './QcCompanyTaskResultDialog.vue' + +const displayItems = computed(() => buildTaskListDisplayItems(tableData.value)) +``` + +- [x] **Step 2: 改卡片网格模板** + +将原 `v-for="row in tableData"` 改为 `v-for="item in displayItems"`: + +- `item.kind === 'lab' | 'orphan-third'`:沿用现有 `qc-task-card` 模板,数据用 `item.task`(orphan 第三方仍走原单任务「填写结果」弹窗,避免无公司维度) +- `item.kind === 'company'`:新公司卡 UI(对齐 Demo) + +公司卡要点: + +```vue +
+
+ {{ item.companyName }} +
第三方 · {{ item.tasks.length }} 个检测项目
+
+ + {{ companyProgressText(item) }} + + 第三方检测 +
+
+
+ {{ uniqueAssigneeNames(item.tasks) }} + + 填写结果 + + + 查看 + +
+
+``` + +```ts +function companyProgressText(item: QcTaskListCompanyItem) { + const { completed, total } = companyGroupProgress(item.tasks) + if (completed === 0) return '待执行' + if (completed === total) return `${completed}/${total} 已完成` + return `${completed}/${total} 已完成` +} + +function companyProgressTagType(item: QcTaskListCompanyItem) { + const { completed, total } = companyGroupProgress(item.tasks) + if (total > 0 && completed === total) return 'success' + if (completed > 0) return 'warning' + return 'info' +} + +function canFillCompany(item: QcTaskListCompanyItem) { + return item.tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t)) +} + +const companyDialogVisible = ref(false) +const companyDialogReadonly = ref(false) +const companyDialogTasks = ref([]) +const companyDialogName = ref('') + +function openCompanyResult(item: QcTaskListCompanyItem, forceReadonly?: boolean) { + // mine:tasks 已是当前用户可见子集(列表本身已过滤);若工单页全量则可写=全部 + // 若 mine 模式下列表含非本人任务(通常不会),再 filter isCurrentUserAssignee + let tasks = item.tasks + if (listMode.value === 'mine') { + tasks = tasks.filter((t) => isCurrentUserAssignee(t)) + } + if (!tasks.length) return + companyDialogName.value = item.companyName + companyDialogTasks.value = tasks + companyDialogReadonly.value = + forceReadonly === true || !tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t)) + companyDialogVisible.value = true +} +``` + +挂载: + +```vue + +``` + +自检卡的开始 / 填写结果 / 查看 / 删除 **保持原函数**,不要改到公司路径。 + +- [x] **Step 3: 样式** + +公司卡增加浅绿边框/浅底(`--primary-bg`),与 Demo 一致;勿引入小程序色 `#0a9f6d`。 + +- [x] **Step 4: 手动验收(对照 spec §8)** + +在 `pnpm dev` 下验证: + +1. 质检工单 `inbound_cell_storage`:自检卡数量不变;第三方变公司卡 +2. 打开公司弹窗:多参数三列;状态/结论单选 +3. 保存后同公司各任务备注/附件一致(工单页) +4. 我的质检任务:仅本人负责项进弹窗与保存 +5. 自检「开始」仍可用 + +- [x] **Step 5: Commit**(仅当用户要求提交时) + +```bash +git add web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue \ + web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue \ + web-admin/src/utils/qc-company-task-group.ts \ + web-admin/src/utils/qc-company-task-group.test.ts +git commit -m "$(cat <<'EOF' +feat(qc): group third-party QC tasks by company on work-order cards + +EOF +)" +``` + +--- + +### Task 4: 图谱更新与收尾 + +**Files:** +- 运行:`./scripts/update-graph.sh frontend`(或 web-admin 对应入口) + +- [x] **Step 1: 更新 graphify** + +```bash +cd /Users/wuzilong/work/00code/zkxw && ./scripts/update-graph.sh frontend +``` + +- [x] **Step 2: 将 spec 状态改为已实现(实现完成后)** + +把 `docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md` 文首「待用户审阅」改为「已实现」并注明完成日期。 + +--- + +## Spec coverage checklist + +| Spec 要求 | Task | +|-----------|------| +| 自检不合并 | Task 3 | +| 第三方按公司聚合 | Task 1 + 3 | +| 空 companyId 降级 | Task 1 | +| 分项参数/状态/结论(现网单选) | Task 2 | +| 共享备注/材料 + 任一项完成则材料必填 | Task 2 | +| 工单全量同步 / 我的任务子集 | Task 2 + 3 | +| 方案 A 循环 complete-with-result | Task 2 | +| 打开回显最近非空共享字段 | Task 1 + 2 | +| 不做配置检测/DDL/整公司删除 | 明确不在 File map | + +## 执行说明 + +- 提交步骤默认 **跳过**,除非用户明确要求 commit。 +- 两页共用 `QcWorkOrderTaskSubList`,改一处即可覆盖质检工单与我的质检任务。 diff --git a/docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md b/docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md new file mode 100644 index 00000000..5fcd754c --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md @@ -0,0 +1,160 @@ +# 质检任务第三方按公司聚合卡片 — 设计 + +> 日期:2026-08-28 +> 状态:已实现 +> 完成日期:2026-08-28 +> 范围:`web-admin` 质检工单、我的质检任务(批次内任务列表) +> 方案:A(前端聚合 + 循环调用现有单任务保存接口) +> 交互原型:`.superpowers/brainstorm/80309-1787910046/content/001-qc-company-card-demo.html` +> 实现计划:`docs/superpowers/plans/2026-08-28-qc-third-party-company-card.md` + +## 1. 背景与目标 + +现状:检测批次下每个质检项目一张任务卡。第三方公司项目多时,列表卡片过多。 + +目标: + +- **实验室自检**:不合并,仍一项目一卡,交互保持现状。 +- **第三方检测**:同一质检批次下,同一公司的全部(可见)项目聚合成 **一张公司卡**;点击打开弹窗,在弹窗内按项目填写。 +- **分项填写**:检测参数、任务状态、结论按单个项目填写。 +- **共享填写**:备注、检测材料只填一份,并按规则同步到本次保存的可写任务集合。 + +## 2. 已确认决策 + +| 决策点 | 结论 | +|--------|------| +| 自检是否合并 | 否,保持单项目卡 | +| 第三方聚合维度 | 同工单、同批次、同 `companyId` | +| 实现路径 | 方案 A:前端聚合 + 复用 `POST /qc-tasks/{id}/complete-with-result` | +| 共享字段落库 | 仍写在各任务的 `remark` / `third_party_attachments`(冗余同步,不改表) | +| 质检工单页保存范围 | 该批次该公司下全部第三方任务 | +| 我的质检任务页 | 弹窗只展示、只保存当前用户负责的任务;备注/材料也只写这些任务 | +| 任务状态 / 结论 UI | 沿用现网单选:状态「待执行 / 已完成」;结论「待结论 / 通过 / 不通过」;选已完成时禁用待结论且结论必填 | +| 本期不做 | 改配置检测 UI、新建批次-公司实体、公司卡批量删除、附件导出目录重构 | + +## 3. 列表展示 + +### 3.1 层级 + +工单 → 检测批次 Tab → 任务区(`QcWorkOrderBatchSubList` → 任务子列表)。 + +任务区内混合两类卡片: + +1. **自检卡**(`taskType = sysz`):现有紧凑卡,不改行为。 +2. **公司卡**(由 `taskType = dsfjc` 按 `companyId` 聚合):新增样式与交互。 + +排序:先自检卡(保持原任务顺序),再公司卡(按公司名或首次出现顺序稳定排序);公司卡内项目顺序与配置/原任务顺序一致。 + +### 3.2 公司卡字段 + +| 元素 | 规则 | +|------|------| +| 主标题 | `companyName` | +| 副文案 | `第三方 · N 个检测项目`(N = 该卡可见任务数) | +| 进度 Tag | `已完成数/总数`;全完成 success,部分 warning,全未完成 info | +| 类型 Tag | `第三方检测` | +| 负责人 | 可见任务 assignee 去重后拼接 | +| 主按钮 | 有可填权限 →「填写结果」;否则「查看」 | +| 点击/双击 | 与主按钮一致,打开公司弹窗 | + +第三方现网不需要「开始」;公司卡不提供开始。 + +### 3.3 我的质检任务聚合 + +- 仅用当前用户作为负责人的第三方任务参与聚合。 +- 某公司下自己负责 0 项 → 不展示该公司卡。 + +### 3.4 异常数据 + +- `companyId` 为空的第三方任务:不并入其它公司卡,各自降级为单项目卡(或单独「未归属公司」卡,实现时二选一,**推荐降级为单项目卡**),避免误聚合。 + +### 3.5 删除 + +本期公司卡不提供「删除整公司」。单任务删除仍走现有入口(或配置「调整检测」);公司弹窗内不做删除,控制范围。 + +## 4. 公司弹窗 + +### 4.1 结构(自上而下) + +1. 标题:`填写检测结果 · {公司名}` / `查看检测结果 · {公司名}` +2. **检测项目**(可写/可见集合内每个任务一块) + - 项目名 + 状态 Tag + - **检测参数**:按项目参数模板,三列栅格(文本 / 数字 / 下拉,与现网单任务弹窗一致) + - **检测结论**:现网单选组(任务状态、结论) +3. **备注与检测材料**(整弹窗一份) + - 备注 textarea + - 检测材料上传组件(与现网第三方附件一致) + - 若可写集合中 **任一项** 任务状态为「已完成」,则检测材料必填 + +只读模式:全部控件禁用,无保存按钮(或仅关闭)。 + +### 4.2 打开回显 + +- 各项目:各自 `results`、任务状态、结论。 +- 共享备注 / 材料:在本次弹窗任务集合中,取 **最近 `updateTime` 且对应字段非空** 的值;皆空则空白。 + +### 4.3 保存 + +对可写集合中每个任务依次调用现有: + +`POST /qc-tasks/{id}/complete-with-result` + +Body 要点: + +- 分项:`results`、`taskStatus`、`conclusion` +- 共享:同一份 `remark`、`thirdPartyAttachments` +- 校验:逐项校验状态/结论规则(已完成必须结论;与现网一致) +- 材料:存在任一「已完成」时,共享材料必填(整单校验一次) + +失败策略:逐任务提交;失败时提示失败项及原因,已成功项保留。不做跨任务 DB 事务(方案 A 取舍,写入 spec 明示)。 + +保存成功后刷新批次任务列表 / 触发 `tasks-changed`。 + +## 5. 前端改动面(实现指引) + +主要落点(实现计划阶段再拆任务): + +| 区域 | 改动 | +|------|------| +| `QcWorkOrderTaskSubList.vue`(或抽出 composable/子组件) | 将 `tableData` 拆成自检列表 + 按公司分组;渲染公司卡;公司弹窗 | +| 新建组件(建议) | 如 `QcCompanyTaskResultDialog.vue`:多项目参数 + 共享备注/材料 | +| 聚合工具 | 如 `groupThirdPartyTasksByCompany(tasks)` | +| API | 不新增后端接口;复用任务详情、参数、`complete-with-result` | +| 质检工单 / 我的质检任务 | 共用上述子列表,靠 `listMode` 区分可写集合 | + +查看弹窗、工单系统查看里「第三方项目」只读展示:可后续对齐公司分组,**非本期必做**(列表填写路径优先)。 + +## 6. 后端与数据 + +- **不改表结构**。`biz_qc_task` 仍一项目一任务;`company_id` / `company_name` 已有。 +- 备注、附件继续按任务冗余存储;同次保存写入相同值。 +- 工单完成判定、附件导出等现有逻辑不变(依赖任务级字段)。 + +## 7. 风险与缓解 + +| 风险 | 缓解 | +|------|------| +| 多次请求部分失败 | 明确提示;支持再次打开补救;后续可升级方案 B 批量 API | +| 历史同公司任务备注/附件不一致 | 打开时按「最近非空」回显;保存后统一为本次共享值 | +| 我的任务子集与工单全量语义不同 | UI 与保存范围按 `listMode` 分支;文档与验收分开写 | +| 公司弹窗项目多、参数多导致过长 | `dialog-form-layout` 仅 body 滚动;项目块可考虑默认展开全部(首版不做折叠,除非验收反馈) | + +## 8. 验收场景(摘要) + +1. 同批次配置 2 个自检 + 公司 A 3 项 + 公司 B 1 项 → 列表为 2 张自检卡 + 2 张公司卡。 +2. 打开公司 A 弹窗,可见 3 个项目块;多参数项目三列展示;状态/结论为单选。 +3. 填写共享备注与材料并保存 → 工单页下该公司该批 3 个任务备注与附件一致。 +4. 我的质检任务:用户仅负责公司 A 的 2/3 项 → 公司卡显示 2 项;保存只更新这 2 项。 +5. 任一项选「已完成」且无材料 → 保存被拦截。 +6. 自检卡「开始 / 填写结果」行为与改造前一致。 +7. 全完成公司卡进度为 success,主按钮可为查看(视权限)。 + +## 9. 工作量粗估 + +约 5~8 人天(前端聚合 + 公司弹窗为主;回归两页多 Tab;无后端 DDL)。 + +## 10. 非目标 + +- 移动端 / H5 同步改造 +- OpenSpec 工程规范变更归档(若需可另开 change) +- 方案 C(共享实体表) diff --git a/web-admin/src/utils/qc-company-task-group.test.ts b/web-admin/src/utils/qc-company-task-group.test.ts new file mode 100644 index 00000000..548a1bb1 --- /dev/null +++ b/web-admin/src/utils/qc-company-task-group.test.ts @@ -0,0 +1,137 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + buildTaskListDisplayItems, + pickSharedRemarkAndAttachments, + companyGroupProgress, + uniqueAssigneeNames +} from './qc-company-task-group' + +type TestQcTask = { + id: string + taskType: string + taskNo?: string + itemCode?: string + itemName?: string + taskStatus?: string + workOrderId?: string + companyId?: string + companyName?: string + remark?: string + thirdPartyAttachments?: string + updateTime?: string + assigneeUserNames?: string + attachmentFiles?: Array<{ fileUrl: string; fileName?: string; fileType?: string }> +} + +function task(partial: Partial & Pick): TestQcTask { + return { + taskNo: 'T1', + itemCode: 'I', + itemName: 'Item', + taskStatus: 'dzx', + workOrderId: 'wo1', + ...partial + } +} + +describe('buildTaskListDisplayItems', () => { + it('keeps lab tasks as single cards and groups third-party by companyId', () => { + const tasks = [ + task({ id: 'l1', taskType: 'sysz', itemName: '无菌' }), + task({ + id: 't1', + taskType: 'dsfjc', + companyId: 'c1', + companyName: '金域', + itemName: '乙肝' + }), + task({ + id: 't2', + taskType: 'dsfjc', + companyId: 'c1', + companyName: '金域', + itemName: '丙肝' + }), + task({ + id: 't3', + taskType: 'dsfjc', + companyId: 'c2', + companyName: '迪安', + itemName: 'HIV' + }) + ] + const items = buildTaskListDisplayItems(tasks) + assert.equal(items.length, 3) + assert.equal(items[0].kind, 'lab') + assert.equal(items[0].kind === 'lab' && items[0].task.id, 'l1') + assert.equal(items[1].kind, 'company') + if (items[1].kind === 'company') { + assert.equal(items[1].companyId, 'c1') + assert.equal(items[1].tasks.length, 2) + } + assert.equal(items[2].kind, 'company') + if (items[2].kind === 'company') { + assert.equal(items[2].companyId, 'c2') + } + }) + + it('falls back to single lab-like card when third-party has empty companyId', () => { + const tasks = [ + task({ id: 't0', taskType: 'dsfjc', companyId: '', companyName: '', itemName: '孤儿' }) + ] + const items = buildTaskListDisplayItems(tasks) + assert.equal(items.length, 1) + assert.equal(items[0].kind, 'orphan-third') + }) +}) + +describe('companyGroupProgress', () => { + it('counts ywc vs total', () => { + const p = companyGroupProgress([ + task({ id: 'a', taskType: 'dsfjc', taskStatus: 'ywc' }), + task({ id: 'b', taskType: 'dsfjc', taskStatus: 'dzx' }) + ]) + assert.deepEqual(p, { completed: 1, total: 2 }) + }) +}) + +describe('pickSharedRemarkAndAttachments', () => { + it('picks latest non-empty remark and attachments by updateTime', () => { + const picked = pickSharedRemarkAndAttachments([ + task({ + id: 'a', + taskType: 'dsfjc', + remark: '旧备注', + thirdPartyAttachments: 'http://a.pdf', + updateTime: '2026-08-01 10:00:00', + attachmentFiles: [{ fileUrl: 'http://a.pdf', fileName: 'a.pdf' }] + }), + task({ + id: 'b', + taskType: 'dsfjc', + remark: '新备注', + thirdPartyAttachments: '', + updateTime: '2026-08-28 12:00:00', + attachmentFiles: [] + }) + ]) + assert.equal(picked.remark, '新备注') + assert.equal(picked.thirdPartyAttachments, 'http://a.pdf') + assert.equal(picked.attachmentFiles?.length, 1) + }) +}) + +describe('uniqueAssigneeNames', () => { + it('dedupes assignee names split by comma or顿号', () => { + const names = uniqueAssigneeNames([ + task({ id: 'a', taskType: 'dsfjc', assigneeUserNames: '张三,李四' }), + task({ id: 'b', taskType: 'dsfjc', assigneeUserNames: '李四、王五' }) + ]) + assert.equal(names, '张三、李四、王五') + }) + + it('returns em dash when no assignees', () => { + assert.equal(uniqueAssigneeNames([task({ id: 'a', taskType: 'dsfjc' })]), '—') + }) +}) diff --git a/web-admin/src/utils/qc-company-task-group.ts b/web-admin/src/utils/qc-company-task-group.ts new file mode 100644 index 00000000..7a95db20 --- /dev/null +++ b/web-admin/src/utils/qc-company-task-group.ts @@ -0,0 +1,106 @@ +import type { QcTask } from '../api/qc-task' + +export type QcTaskListLabItem = { kind: 'lab'; task: QcTask } +export type QcTaskListOrphanThirdItem = { kind: 'orphan-third'; task: QcTask } +export type QcTaskListCompanyItem = { + kind: 'company' + companyId: string + companyName: string + tasks: QcTask[] +} +export type QcTaskListDisplayItem = + | QcTaskListLabItem + | QcTaskListOrphanThirdItem + | QcTaskListCompanyItem + +function trimId(v?: string) { + return (v || '').trim() +} + +/** 自检卡在前(原序);有 companyId 的第三方按首次出现公司顺序分组;无 companyId 降级为 orphan-third */ +export function buildTaskListDisplayItems(tasks: QcTask[]): QcTaskListDisplayItem[] { + const lab: QcTaskListDisplayItem[] = [] + const orphans: QcTaskListDisplayItem[] = [] + const companyOrder: string[] = [] + const companyMap = new Map() + + for (const t of tasks || []) { + if ((t.taskType || '').trim() === 'sysz') { + lab.push({ kind: 'lab', task: t }) + continue + } + if ((t.taskType || '').trim() !== 'dsfjc') { + lab.push({ kind: 'lab', task: t }) + continue + } + const cid = trimId(t.companyId) + if (!cid) { + orphans.push({ kind: 'orphan-third', task: t }) + continue + } + let g = companyMap.get(cid) + if (!g) { + g = { + kind: 'company', + companyId: cid, + companyName: (t.companyName || '').trim() || '第三方公司', + tasks: [] + } + companyMap.set(cid, g) + companyOrder.push(cid) + } + g.tasks.push(t) + } + + return [...lab, ...companyOrder.map((id) => companyMap.get(id)!), ...orphans] +} + +export function companyGroupProgress(tasks: QcTask[]) { + const list = tasks || [] + const completed = list.filter((t) => (t.taskStatus || '').trim() === 'ywc').length + return { completed, total: list.length } +} + +function timeMs(v?: string) { + if (!v) return 0 + const n = Date.parse(String(v).replace(/-/g, '/')) + return Number.isFinite(n) ? n : 0 +} + +export function pickSharedRemarkAndAttachments(tasks: QcTask[]): { + remark: string + thirdPartyAttachments: string + attachmentFiles: NonNullable +} { + const list = [...(tasks || [])].sort((a, b) => timeMs(b.updateTime) - timeMs(a.updateTime)) + let remark = '' + let thirdPartyAttachments = '' + let attachmentFiles: NonNullable = [] + for (const t of list) { + if (!remark && (t.remark || '').trim()) remark = (t.remark || '').trim() + const urls = (t.thirdPartyAttachments || '').trim() + const files = t.attachmentFiles || [] + if (!thirdPartyAttachments && (urls || files.length)) { + thirdPartyAttachments = urls + attachmentFiles = files.length ? [...files] : [] + } + if (remark && (thirdPartyAttachments || attachmentFiles.length)) break + } + return { remark, thirdPartyAttachments, attachmentFiles } +} + +export function uniqueAssigneeNames(tasks: QcTask[]): string { + const names: string[] = [] + const seen = new Set() + for (const t of tasks || []) { + const raw = (t.assigneeUserNames || '').trim() + if (!raw) continue + for (const part of raw.split(/[,,、]/)) { + const n = part.trim() + if (!n || seen.has(n)) continue + seen.add(n) + names.push(n) + } + } + return names.join('、') || '—' +} diff --git a/web-admin/src/views/production/production-work-order/SampleReceptionDialog.vue b/web-admin/src/views/production/production-work-order/SampleReceptionDialog.vue index 7e7a6599..a4307b54 100644 --- a/web-admin/src/views/production/production-work-order/SampleReceptionDialog.vue +++ b/web-admin/src/views/production/production-work-order/SampleReceptionDialog.vue @@ -184,7 +184,6 @@ v-model="prodForm[field.key]" clearable :maxlength="field.maxLength" - :show-word-limit="!!field.maxLength" /> + + + + + diff --git a/web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue b/web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue index 812fbd18..94b5773e 100644 --- a/web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue +++ b/web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue @@ -25,84 +25,138 @@
-
-
- {{ row.itemName || '—' }} -
- - {{ taskStatusLabel(row.taskStatus) }} - - - {{ taskTypeLabel(row.taskType) }} - - - {{ - row.conclusionLabel || qcConclusionDisplayLabel(row.conclusion) - }} - +
@@ -350,6 +404,15 @@ + + ([]) const loading = ref(false) + const displayItems = computed(() => buildTaskListDisplayItems(tableData.value)) + + function displayItemKey(item: QcTaskListDisplayItem, idx: number) { + if (item.kind === 'company') return `company_${item.companyId || idx}` + return item.task.id || `${item.kind}_${idx}` + } function isCurrentUserAssignee(row: QcTask) { const uid = String(userStore.info?.id || userStore.info?.userId || '').trim() @@ -638,6 +715,42 @@ return (row.assigneeUserIds || []).some((id) => String(id) === uid) } + function companyProgressText(item: QcTaskListCompanyItem) { + const { completed, total } = companyGroupProgress(item.tasks) + if (completed === 0) return '待执行' + return `${completed}/${total} 已完成` + } + + function companyProgressTagType(item: QcTaskListCompanyItem) { + const { completed, total } = companyGroupProgress(item.tasks) + if (total > 0 && completed === total) return 'success' + if (completed > 0) return 'warning' + return 'info' + } + + function canFillCompany(item: QcTaskListCompanyItem) { + return item.tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t)) + } + + const companyDialogVisible = ref(false) + const companyDialogReadonly = ref(false) + const companyDialogTasks = ref([]) + const companyDialogName = ref('') + + function openCompanyResult(item: QcTaskListCompanyItem, forceReadonly?: boolean) { + let tasks = item.tasks + if (listMode.value === 'mine') { + tasks = tasks.filter((t) => isCurrentUserAssignee(t)) + } + if (!tasks.length) return + companyDialogName.value = item.companyName + companyDialogTasks.value = tasks + companyDialogReadonly.value = + forceReadonly === true || + !tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t)) + companyDialogVisible.value = true + } + const resultVisible = ref(false) const resultLoading = ref(false) const resultTaskId = ref('') @@ -1588,6 +1701,24 @@ gap: 4px; padding: 6px 8px; } + + &--company { + border-color: #a7f3d0; + background: #ecfdf5; + cursor: pointer; + + &:hover { + border-color: var(--primary, #10b981); + box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15); + } + } + } + + .qc-task-card__sub { + width: 100%; + font-size: 11px; + color: var(--mint-text-muted, #334155); + line-height: 1.35; } .qc-task-card__line {