diff --git a/docs/superpowers/plans/2026-08-30-pl-plan-product-fields-arrange-required.md b/docs/superpowers/plans/2026-08-30-pl-plan-product-fields-arrange-required.md new file mode 100644 index 000000000..079ab7795 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-pl-plan-product-fields-arrange-required.md @@ -0,0 +1,353 @@ +# 运输计划产品字段安排前必填 — 实现计划 + +> **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 (`- [ ]`) syntax for tracking. + +**Goal:** 装/送货产品行的单位、计划数量、包装规格、送货方式在运输安排时前端硬拦;表单始终显示必填星号;普通保存不拦截。 + +**Architecture:** 纯函数校验抽到 `web-admin/src/utils/pl-plan-stop-product-arrange-required.ts`(便于 `node:test`);`pl-plan-form-dialog` 在 `saveAndArrange`/`rearrange` 分支调用并 `ElMessage.warning`;`pl-plan-stop-product-table` 四字段标签加红色 `*`。不改后端与列表预检。 + +**Tech Stack:** Vue 3、Element Plus、TypeScript、`node:test` + `pnpm exec tsx --test` + +**Spec:** `docs/superpowers/specs/2026-08-30-pl-plan-product-fields-arrange-required-design.md` + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| Create: `web-admin/src/utils/pl-plan-stop-product-arrange-required.ts` | 扫描装/送货产品行,返回第一处缺失提示文案(或 `null`) | +| Create: `web-admin/src/utils/pl-plan-stop-product-arrange-required.test.ts` | 单元测试 | +| Modify: `web-admin/src/components/business/pl-plan-form-dialog/index.vue` | `validateStopProductsForArrange` + 安排分支调用 | +| Modify: `web-admin/src/components/business/pl-plan-stop-product-table/index.vue` | compact + 表格态四字段必填星号 | + +--- + +### Task 1: 纯函数校验 + 失败测试 + +**Files:** +- Create: `web-admin/src/utils/pl-plan-stop-product-arrange-required.ts` +- Create: `web-admin/src/utils/pl-plan-stop-product-arrange-required.test.ts` + +- [ ] **Step 1: 写失败测试(先不实现 util,或先写空实现让断言失败)** + +创建 `web-admin/src/utils/pl-plan-stop-product-arrange-required.test.ts`: + +```ts +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { findFirstStopProductArrangeGap } from './pl-plan-stop-product-arrange-required' + +function row(partial: Record = {}) { + return { + productId: 'p1', + unit: '吨', + planQty: 10, + packageSpec: '槽罐', + deliveryMethod: '送货上门', + ...partial + } +} + +describe('findFirstStopProductArrangeGap', () => { + it('returns null when load and deliveries are complete', () => { + assert.equal( + findFirstStopProductArrangeGap({ + loadProducts: [row()], + deliveries: [{ products: [row()] }] + }), + null + ) + }) + + it('flags load product missing fields including planQty 0', () => { + assert.equal( + findFirstStopProductArrangeGap({ + loadProducts: [row({ unit: '', planQty: 0, packageSpec: ' ', deliveryMethod: null })], + deliveries: [] + }), + '安排运输前请完善装货产品第1行:单位、计划数量、包装规格、送货方式' + ) + }) + + it('flags delivery segment product and stops at first gap', () => { + assert.equal( + findFirstStopProductArrangeGap({ + loadProducts: [row()], + deliveries: [ + { products: [row()] }, + { products: [row({ packageSpec: '' }), row({ deliveryMethod: '' })] } + ] + }), + '安排运输前请完善送货段#2产品第1行:包装规格' + ) + }) + + it('treats null/undefined/NaN planQty as missing', () => { + assert.equal( + findFirstStopProductArrangeGap({ + loadProducts: [row({ planQty: undefined })], + deliveries: [] + }), + '安排运输前请完善装货产品第1行:计划数量' + ) + assert.equal( + findFirstStopProductArrangeGap({ + loadProducts: [row({ planQty: Number.NaN })], + deliveries: [] + }), + '安排运输前请完善装货产品第1行:计划数量' + ) + }) +}) +``` + +- [ ] **Step 2: 写最小实现使测试通过** + +创建 `web-admin/src/utils/pl-plan-stop-product-arrange-required.ts`: + +```ts +export type ArrangeRequiredProductLike = { + unit?: string | null + planQty?: number | null + packageSpec?: string | null + deliveryMethod?: string | null +} + +export type ArrangeRequiredDeliveryLike = { + products?: ArrangeRequiredProductLike[] | null +} + +const FIELD_LABELS = { + unit: '单位', + planQty: '计划数量', + packageSpec: '包装规格', + deliveryMethod: '送货方式' +} as const + +type FieldKey = keyof typeof FIELD_LABELS + +function missingFields(row: ArrangeRequiredProductLike): FieldKey[] { + const gaps: FieldKey[] = [] + if (!String(row.unit ?? '').trim()) gaps.push('unit') + const qty = Number(row.planQty) + if (!Number.isFinite(qty) || qty <= 0) gaps.push('planQty') + if (!String(row.packageSpec ?? '').trim()) gaps.push('packageSpec') + if (!String(row.deliveryMethod ?? '').trim()) gaps.push('deliveryMethod') + return gaps +} + +function formatGap(prefix: string, fields: FieldKey[]): string { + return `${prefix}${fields.map((k) => FIELD_LABELS[k]).join('、')}` +} + +/** 返回第一处产品字段缺失提示;齐全则 null */ +export function findFirstStopProductArrangeGap(input: { + loadProducts?: ArrangeRequiredProductLike[] | null + deliveries?: ArrangeRequiredDeliveryLike[] | null +}): string | null { + const loadProducts = input.loadProducts || [] + for (let i = 0; i < loadProducts.length; i += 1) { + const fields = missingFields(loadProducts[i]) + if (fields.length) { + return formatGap(`安排运输前请完善装货产品第${i + 1}行:`, fields) + } + } + const deliveries = input.deliveries || [] + for (let d = 0; d < deliveries.length; d += 1) { + const products = deliveries[d].products || [] + for (let i = 0; i < products.length; i += 1) { + const fields = missingFields(products[i]) + if (fields.length) { + return formatGap(`安排运输前请完善送货段#${d + 1}产品第${i + 1}行:`, fields) + } + } + } + return null +} +``` + +- [ ] **Step 3: 跑测试确认通过** + +Run: + +```bash +cd web-admin && pnpm exec tsx --test src/utils/pl-plan-stop-product-arrange-required.test.ts +``` + +Expected: all tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/utils/pl-plan-stop-product-arrange-required.ts \ + web-admin/src/utils/pl-plan-stop-product-arrange-required.test.ts +git commit -m "$(cat <<'EOF' +feat(pl): add arrange-required check for stop product fields + +EOF +)" +``` + +--- + +### Task 2: 弹窗安排分支接入校验 + +**Files:** +- Modify: `web-admin/src/components/business/pl-plan-form-dialog/index.vue` + +- [ ] **Step 1: 增加 import** + +在 script 既有 `@/utils/pl-plan-stop-form` 等 import 旁增加: + +```ts +import { findFirstStopProductArrangeGap } from '@/utils/pl-plan-stop-product-arrange-required' +``` + +- [ ] **Step 2: 增加包装函数(紧挨 `validateContractsForArrange`)** + +```ts +/** 安排运输前:装/送货产品单位、计划数量、包装规格、送货方式必填 */ +function validateStopProductsForArrange() { + const gap = findFirstStopProductArrangeGap({ + loadProducts: loadForm.value.products, + deliveries: deliveryForms.value + }) + if (gap) { + ElMessage.warning(gap) + return false + } + return true +} +``` + +- [ ] **Step 3: 在 `handleSubmit` 安排分支调用** + +将安排分支末尾从: + +```ts +if (!validateContractsForArrange()) return +``` + +改为: + +```ts +if (!validateContractsForArrange()) return +if (!validateStopProductsForArrange()) return +``` + +确认:仅在 `mode === 'saveAndArrange' || mode === 'rearrange'` 内调用;`mode === 'save'` 不调用。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/components/business/pl-plan-form-dialog/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): block arrange when stop product required fields missing + +EOF +)" +``` + +--- + +### Task 3: 产品表必填星号 UI + +**Files:** +- Modify: `web-admin/src/components/business/pl-plan-stop-product-table/index.vue` + +- [ ] **Step 1: compact 态四字段标签加星号** + +将「单位」「计划数量」「包装规格」「送货方式」的 label 改为(示例): + +```html +单位 +``` + +其余三字段同样加 ``。 + +- [ ] **Step 2: 表格态四列表头加星号** + +把对应 `el-table-column` 的 `label="单位"` 等改为自定义 header,例如: + +```vue + + + + +``` + +对「计划数量」「包装规格」「送货方式」三列同样处理(保留原有 `prop`/`min-width`/`align`/`v-if` 等属性)。 + +- [ ] **Step 3: 增加样式** + +在同文件 `