From 13dd73069e004adfe6045ea26ba0babff0b8c590 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 10:28:58 +0800 Subject: [PATCH 01/16] fix: align sofia certificate print fields --- .../barcodePrintCertificate.test.ts | 46 +++++++++++++++++++ .../barcode-print/barcodePrintCertificate.ts | 5 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts index 4b85070a..523d11c6 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts @@ -1,6 +1,52 @@ import { __barcodePrintCertificateTestUtils } from "./barcodePrintCertificate"; import { SOFIA_CARTON_BARCODE_QR_LAYOUT } from "./barcodePrintSofiaCartonLayout"; +describe("Sofia certificate field positioning", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it("moves all six values upward by three rows without changing their data or horizontal layout", () => { + jest.useFakeTimers().setSystemTime(new Date("2026-09-04T12:00:00.000Z")); + const element = __barcodePrintCertificateTestUtils.renderSofiaCertificate( + { + barcode: "GBSFY-SJ038036N1410V01", + productCategory: "书架", + color: "经典北美黑胡桃色", + procode: "SFY-001", + brandProcode: "SFY-BRAND-001", + size: "100*40*180", + productSpec: "100*40*180", + hangtagMaterial: "人造板", + qualifiedCertMainMaterial: "人造板", + executionStandard: "GB/T 3324-2017", + }, + "", + {}, + "data:image/png;base64,background", + ); + const values = Array.from(element.querySelectorAll(".item")); + + expect(values.map((value) => value.style.top)).toEqual([ + "200px", + "266px", + "332px", + "398px", + "464px", + "530px", + ]); + expect(values.map((value) => value.style.left)).toEqual(Array(6).fill("310px")); + expect(values.map((value) => value.textContent)).toEqual([ + "书架-经典北美黑胡桃色", + "SFY-001", + "100*40*180", + "GB/T 3324-2017", + "QC01", + "2026-08-25", + ]); + }); +}); + describe("Sofia carton certificate rendering", () => { it("renders the current barcode QR code to the left of the fixed official-account QR code", () => { const qrCodeDataUrl = "data:image/png;base64,barcode-qr"; diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts index 60f24249..07e5f46b 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts @@ -110,6 +110,8 @@ const PORTRAIT_FIELD_TOP = 883; const PORTRAIT_FIELD_ROW_GAP = 68; const PORTRAIT_RIGHT_FIRST_ROW_TOP_OFFSET = 0; const PORTRAIT_VALUE_VISUAL_OFFSET = -8; +const SOFIA_CERTIFICATE_FIELD_TOP = 200; +const SOFIA_CERTIFICATE_FIELD_ROW_GAP = 66; function trimText(value: unknown) { return String(value ?? "").trim(); @@ -524,7 +526,7 @@ function renderSofiaCertificate( color: "#000000", lineHeight: "32px", left: "310px", - top: `${398 + index * 66}px`, + top: `${SOFIA_CERTIFICATE_FIELD_TOP + index * SOFIA_CERTIFICATE_FIELD_ROW_GAP}px`, }); }); @@ -641,6 +643,7 @@ export const __barcodePrintCertificateTestUtils = { getLandscapeProductNameFontSize, renderCommonWithMaker, renderCommonWithoutMaker, + renderSofiaCertificate, renderSofiaCarton, }; From 99f004a59350812028bf280857f89f7c2f822eb2 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 10:55:55 +0800 Subject: [PATCH 02/16] fix(finished-product-progress): enforce first-due-date protection on import updates Import rows must not bypass the confirmed-first-due-date protection. Drop the allowDueDateChanges flag from the import update path so first-due-date changes are rejected and weekly due-date changes still require a reason, matching manual edits. Legacy rows without an order-placed date keep their free-correction path. --- .../src/server/services/finished-product-progress.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/furniture-3d-system/src/server/services/finished-product-progress.service.ts b/furniture-3d-system/src/server/services/finished-product-progress.service.ts index 3fcfb5f4..780bd85a 100644 --- a/furniture-3d-system/src/server/services/finished-product-progress.service.ts +++ b/furniture-3d-system/src/server/services/finished-product-progress.service.ts @@ -357,7 +357,7 @@ export class FinishedProductProgressService { previous = await this.repository.findByLegacyOrderKey(importedValues.contractNumber, itemNumber, barcode) } const prepared = previous - ? prepareUpdateValues(previous, importedValues, index + 2, { allowDueDateChanges: true }) + ? prepareUpdateValues(previous, importedValues, index + 2, { allowDueDateChanges: previous.orderPlacedDate == null }) : { values: initializeDueDates(importedValues), orderDueDateChanged: false } const values = prepared.values const changes = changeSet(previous, values) From d08a244fcbe45dca266946d41b961b33097e826d Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 11:23:54 +0800 Subject: [PATCH 03/16] fix(supply-chain): default batch selection operator --- ...rRecommendedProductOperatorDialog.test.tsx | 21 +++++++++++++++++- ...pplierRecommendedProductOperatorDialog.tsx | 14 ++++++++---- ...plier-recommended-products.service.test.ts | 22 +++++++++++++++++++ .../supplier-recommended-products.service.ts | 18 +++++++++------ 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.test.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.test.tsx index d564a8af..2f3aeac3 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.test.tsx @@ -4,6 +4,25 @@ import { SupplierRecommendedProductOperatorDialog } from './SupplierRecommendedP describe('SupplierRecommendedProductOperatorDialog', () => { afterEach(() => jest.restoreAllMocks()) + it('pins and selects the current operator even when the search result does not include them', async () => { + Object.defineProperty(global, 'fetch', { + configurable: true, + value: jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: { operators: [{ id: 'operator-2', name: '运营乙', departmentName: '运营中心' }] } }), + } as Response), + }) + const onConfirm = jest.fn() + render() + + expect(screen.getByRole('radio', { name: /子龙/ })).toBeChecked() + expect(screen.getByText('当前操作人')).toBeInTheDocument() + expect(await screen.findByText('运营乙')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '确认选款' })) + + expect(onConfirm).toHaveBeenCalledWith({ id: 'leader-1', name: '子龙', departmentName: '当前操作人' }) + }) + it('searches and confirms the operator for a batch selection', async () => { Object.defineProperty(global, 'fetch', { configurable: true, @@ -13,7 +32,7 @@ describe('SupplierRecommendedProductOperatorDialog', () => { } as Response), }) const onConfirm = jest.fn() - render() + render() expect(await screen.findByText('运营乙')).toBeInTheDocument() fireEvent.click(screen.getByRole('radio', { name: /运营乙/ })) diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.tsx index 870d0dc3..fb618e36 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductOperatorDialog.tsx @@ -50,7 +50,13 @@ export function SupplierRecommendedProductOperatorDialog({ open, selectedCount, if (!open) return null const nameOf = (operator?: { id: string; name: string }) => operator?.name || '' - const selectedOperator = operators.find((operator) => operator.id === selectedId) + const operatorOptions = currentOperator + ? [ + { ...currentOperator, departmentName: '当前操作人' }, + ...operators.filter((operator) => operator.id !== currentOperator.id), + ] + : operators + const selectedOperator = operatorOptions.find((operator) => operator.id === selectedId) return
{ if (event.target === event.currentTarget && !busy) onCancel() }}>
@@ -67,10 +73,10 @@ export function SupplierRecommendedProductOperatorDialog({ open, selectedCount,
- {loading && operators.length === 0 &&
加载中
} + {loading && operatorOptions.length === 0 &&
加载中
} {!loading && error &&
{error}
} - {!loading && !error && operators.length === 0 &&
没有匹配的运营人员
} - {operators.map((operator) =>
- {feedback &&
- {feedback.message} - -
} +
{ event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}> setFilters({ ...filters, search: event.target.value })} placeholder="搜索供应商、采购、产品代码、材质或发货模式" /> setFilters({ ...filters, category })} /> diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductFeedback.test.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductFeedback.test.tsx new file mode 100644 index 00000000..0b323245 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductFeedback.test.tsx @@ -0,0 +1,51 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { SupplierRecommendedProductFeedback, type SupplierRecommendedProductFeedbackMessage } from './SupplierRecommendedProductFeedback' + +function FeedbackHarness({ initialFeedback }: { initialFeedback: SupplierRecommendedProductFeedbackMessage }) { + const [feedback, setFeedback] = useState(initialFeedback) + return setFeedback(null)} /> +} + +describe('SupplierRecommendedProductFeedback', () => { + afterEach(() => { + jest.useRealTimers() + }) + + it('在右下角提示显示满 5 秒后自动关闭', () => { + jest.useFakeTimers() + render() + + expect(screen.getByRole('status')).toHaveClass('recommended-feedback-success') + expect(screen.getByText('已选款2条')).toBeInTheDocument() + + act(() => jest.advanceTimersByTime(4_999)) + expect(screen.getByRole('status')).toBeInTheDocument() + + act(() => jest.advanceTimersByTime(1)) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('支持手动关闭提示', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: '关闭提示' })) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('收到新提示时重新计算 5 秒显示时间', () => { + jest.useFakeTimers() + const onDismiss = jest.fn() + const firstFeedback = { tone: 'info', message: '请先勾选记录' } as const + const { rerender } = render() + + act(() => jest.advanceTimersByTime(4_000)) + rerender() + act(() => jest.advanceTimersByTime(4_000)) + expect(onDismiss).not.toHaveBeenCalled() + + act(() => jest.advanceTimersByTime(1_000)) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductFeedback.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductFeedback.tsx new file mode 100644 index 00000000..97568d81 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductFeedback.tsx @@ -0,0 +1,40 @@ +'use client' + +import { CircleAlert, CircleCheck, Info, X } from 'lucide-react' +import { useEffect } from 'react' + +export type SupplierRecommendedProductFeedbackMessage = { + tone: 'success' | 'error' | 'info' + message: string +} + +const FEEDBACK_DURATION_MS = 5_000 + +export function SupplierRecommendedProductFeedback({ + feedback, + onDismiss, +}: { + feedback: SupplierRecommendedProductFeedbackMessage | null + onDismiss: () => void +}) { + useEffect(() => { + if (!feedback) return + + const timeoutId = window.setTimeout(onDismiss, FEEDBACK_DURATION_MS) + return () => window.clearTimeout(timeoutId) + }, [feedback, onDismiss]) + + if (!feedback) return null + + const StatusIcon = feedback.tone === 'success' + ? CircleCheck + : feedback.tone === 'error' + ? CircleAlert + : Info + + return
+
+} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css index d8772c49..8017bda6 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css @@ -1,6 +1,6 @@  .recommended-products-page{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:#f5f6f6;color:#27272a} .recommended-products-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;padding:18px 24px;border-bottom:1px solid #e4e4e7;background:#fff} - .recommended-feedback{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:10px 24px 0;border:1px solid;border-radius:5px;padding:9px 12px;font-size:12px}.recommended-feedback button{display:inline-flex;height:22px;width:22px;align-items:center;justify-content:center;border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer}.recommended-feedback button:hover{background:rgba(24,24,27,.08)}.recommended-feedback-success{border-color:#b7dec8;background:#f0faf4;color:#166534}.recommended-feedback-error{border-color:#efc0ba;background:#fff5f4;color:#b42318}.recommended-feedback-info{border-color:#c6d4e5;background:#f4f8fc;color:#28527a} + .recommended-feedback{position:fixed;right:24px;bottom:24px;z-index:110;display:flex;width:min(360px,calc(100vw - 32px));box-sizing:border-box;align-items:flex-start;gap:10px;border:1px solid;border-radius:7px;padding:11px 12px;font-size:12px;line-height:1.5;box-shadow:0 14px 36px rgba(24,24,27,.16);animation:recommended-feedback-in .18s ease-out}.recommended-feedback-icon{margin-top:1px;flex:0 0 auto}.recommended-feedback-message{min-width:0;flex:1;overflow-wrap:anywhere}.recommended-feedback button{display:inline-flex;height:22px;width:22px;flex:0 0 auto;align-items:center;justify-content:center;border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer}.recommended-feedback button:hover{background:rgba(24,24,27,.08)}.recommended-feedback-success{border-color:#b7dec8;background:#f0faf4;color:#166534}.recommended-feedback-error{border-color:#efc0ba;background:#fff5f4;color:#b42318}.recommended-feedback-info{border-color:#c6d4e5;background:#f4f8fc;color:#28527a}@keyframes recommended-feedback-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} .recommended-products-toolbar h1{margin:0;font-size:18px;font-weight:750}.recommended-products-toolbar p{margin:4px 0 0;color:#71717a;font-size:12px}.recommended-products-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}.recommended-view-switch{display:inline-flex;height:38px;align-items:stretch;border:1px solid #d4d4d8;border-radius:5px;background:#fff;overflow:hidden}.recommended-view-switch button{display:inline-flex;align-items:center;gap:5px;border:0;border-right:1px solid #e4e4e7;background:#fff;padding:0 10px;color:#71717a;font-size:12px;font-weight:600;cursor:pointer}.recommended-view-switch button:last-child{border-right:0}.recommended-view-switch button:hover{background:#f4f4f5;color:#3f3f46}.recommended-view-switch button.recommended-view-switch-active{background:#27272a;color:#fff} .recommended-selection-switch{display:inline-flex;height:38px;align-items:stretch;border:1px solid #d4d4d8;border-radius:5px;background:#fff;overflow:hidden}.recommended-selection-switch button{border:0;border-right:1px solid #e4e4e7;background:#fff;padding:0 11px;color:#71717a;font-size:12px;font-weight:650;cursor:pointer}.recommended-selection-switch button:last-child{border-right:0}.recommended-selection-switch button:hover{background:#f4f4f5;color:#3f3f46}.recommended-selection-switch button.recommended-selection-switch-active{background:#27272a;color:#fff} .recommended-control-button,.recommended-primary-button,.recommended-secondary-button,.recommended-page-button,.recommended-row-button,.recommended-icon-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s}.recommended-control-button,.recommended-primary-button,.recommended-secondary-button{height:38px;padding:0 13px}.recommended-primary-button{border-color:#27272a;background:#27272a;color:#fff}.recommended-primary-button:hover{background:#18181b}.recommended-secondary-button:hover,.recommended-control-button:hover,.recommended-row-button:hover,.recommended-page-button:hover{background:#f4f4f5;border-color:#a1a1aa}.recommended-control-button:disabled,.recommended-primary-button:disabled,.recommended-secondary-button:disabled,.recommended-row-button:disabled,.recommended-page-button:disabled{cursor:not-allowed;opacity:.45}.recommended-icon-button{width:30px;height:30px;padding:0;border:0;background:transparent}.recommended-icon-button:hover{background:#f4f4f5}.recommended-row-button{height:29px;padding:0 8px;font-size:11px}.recommended-page-button{height:30px;padding:0 12px}.recommended-danger-button{color:#b42318;border-color:#f2c5c0}.recommended-danger-button:hover{background:#fff1f0;border-color:#d92d20;color:#b42318} @@ -14,4 +14,4 @@ .recommended-selection-person-button{display:inline-flex;height:30px;max-width:132px;align-items:center;gap:4px;overflow:hidden;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:0 7px;color:#52525b;font-size:11px;cursor:pointer}.recommended-selection-person-button:hover{border-color:#a1a1aa;background:#f4f4f5;color:#27272a}.recommended-selection-person-button svg{flex:0 0 auto}.recommended-selection-person-button{white-space:nowrap;text-overflow:ellipsis}.recommended-product-card-selection-person{overflow:hidden;color:#71717a;font-size:11px;font-weight:650;text-overflow:ellipsis;white-space:nowrap} .recommended-operator-overlay{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.3);padding:16px}.recommended-operator-dialog{display:flex;width:min(520px,100%);max-height:min(82vh,680px);flex-direction:column;overflow:hidden;border:1px solid #e4e4e7;border-radius:9px;background:#f8f9f8;box-shadow:0 24px 70px rgba(24,24,27,.2)}.recommended-operator-header{display:flex;align-items:flex-start;justify-content:space-between;border-bottom:1px solid #e4e4e7;background:#fff;padding:20px 22px 16px}.recommended-operator-eyebrow{color:#a1a1aa;font-size:11px;font-weight:650}.recommended-operator-header h2{margin:3px 0 0;color:#27272a;font-size:19px;font-weight:750}.recommended-operator-header p{margin:5px 0 0;color:#71717a;font-size:12px}.recommended-operator-search{display:flex;align-items:center;gap:8px;border-bottom:1px solid #e4e4e7;background:#fff;padding:14px 22px}.recommended-operator-search>input{min-width:0;flex:1;height:36px;border:1px solid #d4d4d8;border-radius:5px;padding:0 10px;color:#27272a;font-size:12px;outline:none}.recommended-operator-search>input:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}.recommended-operator-search .recommended-control-button{height:36px;padding:0 11px}.recommended-operator-list{min-height:180px;flex:1;overflow-y:auto;padding:10px 14px}.recommended-operator-option{display:flex;align-items:center;gap:10px;margin:2px 0;border:1px solid transparent;border-radius:6px;background:#fff;padding:11px 12px;color:#3f3f46;cursor:pointer}.recommended-operator-option:hover,.recommended-operator-option-selected{border-color:#d4d4d8;background:#fff;box-shadow:0 2px 8px rgba(24,24,27,.06)}.recommended-operator-option input{height:16px;width:16px;accent-color:#27272a}.recommended-operator-option-copy{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px}.recommended-operator-option-copy strong{font-size:13px}.recommended-operator-option-copy small{color:#71717a;font-size:11px}.recommended-operator-state{display:flex;align-items:center;justify-content:center;gap:7px;min-height:180px;color:#71717a;font-size:12px}.recommended-operator-state-error{color:#b42318}.recommended-operator-footer{display:flex;justify-content:flex-end;gap:8px;border-top:1px solid #e4e4e7;background:#fff;padding:14px 22px}.recommended-operator-footer .recommended-primary-button,.recommended-operator-footer .recommended-secondary-button{height:36px} .recommended-delete-overlay{position:fixed;inset:0;z-index:100;display:flex;width:100vw;min-height:100dvh;align-items:center;justify-content:center;background:rgba(24,24,27,.28);padding:16px;box-sizing:border-box}.recommended-delete-dialog{width:min(440px,100%);box-sizing:border-box;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 20px 50px rgba(24,24,27,.2)}.recommended-delete-dialog-header{display:flex;align-items:flex-start;gap:11px;padding:20px 20px 14px}.recommended-delete-dialog-header h2{margin:0;color:#27272a;font-size:15px;font-weight:700}.recommended-delete-dialog-header p{margin:6px 0 0;color:#71717a;font-size:12px;line-height:1.6}.recommended-delete-dialog-icon{display:flex;height:30px;width:30px;flex:0 0 auto;align-items:center;justify-content:center;border-radius:50%;background:#fff1f0;color:#b42318}.recommended-delete-dialog-header .recommended-column-close{margin-left:auto;flex:0 0 auto}.recommended-delete-dialog-actions{display:flex;justify-content:flex-end;gap:8px;border-top:1px solid #f4f4f5;padding:14px 20px}.recommended-delete-dialog-actions .recommended-primary-button{border-color:#b42318;background:#b42318}.recommended-delete-dialog-actions .recommended-primary-button:hover{border-color:#912018;background:#912018}.recommended-spin{animation:recommended-spin .9s linear infinite}@keyframes recommended-spin{to{transform:rotate(360deg)}} - @media(max-width:1280px){.recommended-products-filters{grid-template-columns:repeat(4,minmax(140px,1fr))}.recommended-products-filters>input:first-child{grid-column:span 2}}@media(max-width:760px){.recommended-products-toolbar{align-items:flex-start;padding:14px}.recommended-products-actions{justify-content:flex-start}.recommended-products-filters{grid-template-columns:1fr 1fr;padding:10px 14px}.recommended-products-filters>input:first-child{grid-column:1/-1}.recommended-products-table-shell{margin:10px 0 10px 10px}.recommended-products-table-shell>div:first-child{padding-bottom:72px}.recommended-products-card-grid{padding:12px}.recommended-product-card-list{grid-template-columns:1fr}.recommended-product-card-image{height:220px}.recommended-detail-overlay{padding:0}.recommended-detail-dialog{max-height:100dvh;border-radius:0}.recommended-detail-header,.recommended-detail-content,.recommended-detail-footer{padding-left:14px;padding-right:14px}.recommended-detail-grid{grid-template-columns:1fr}.recommended-page-button{padding:0 7px}.recommended-products-toolbar p{display:none}} + @media(max-width:1280px){.recommended-products-filters{grid-template-columns:repeat(4,minmax(140px,1fr))}.recommended-products-filters>input:first-child{grid-column:span 2}}@media(max-width:760px){.recommended-products-toolbar{align-items:flex-start;padding:14px}.recommended-products-actions{justify-content:flex-start}.recommended-products-filters{grid-template-columns:1fr 1fr;padding:10px 14px}.recommended-products-filters>input:first-child{grid-column:1/-1}.recommended-products-table-shell{margin:10px 0 10px 10px}.recommended-products-table-shell>div:first-child{padding-bottom:72px}.recommended-products-card-grid{padding:12px}.recommended-product-card-list{grid-template-columns:1fr}.recommended-product-card-image{height:220px}.recommended-detail-overlay{padding:0}.recommended-detail-dialog{max-height:100dvh;border-radius:0}.recommended-detail-header,.recommended-detail-content,.recommended-detail-footer{padding-left:14px;padding-right:14px}.recommended-detail-grid{grid-template-columns:1fr}.recommended-page-button{padding:0 7px}.recommended-products-toolbar p{display:none}.recommended-feedback{right:14px;bottom:calc(76px + env(safe-area-inset-bottom));width:calc(100vw - 28px)}} From bbb35a44f32e0d14db6a6dd7a047ab803f1376ca Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 12:25:32 +0800 Subject: [PATCH 05/16] fix(barcode-print): tune Sofia certificate field positions --- .../barcode-print/barcodePrintCertificate.test.ts | 14 +++++++------- .../barcode-print/barcodePrintCertificate.ts | 5 ++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts index 523d11c6..674919cd 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts @@ -6,7 +6,7 @@ describe("Sofia certificate field positioning", () => { jest.useRealTimers(); }); - it("moves all six values upward by three rows without changing their data or horizontal layout", () => { + it("uses the warehouse second-pass vertical positions without changing data or horizontal layout", () => { jest.useFakeTimers().setSystemTime(new Date("2026-09-04T12:00:00.000Z")); const element = __barcodePrintCertificateTestUtils.renderSofiaCertificate( { @@ -28,12 +28,12 @@ describe("Sofia certificate field positioning", () => { const values = Array.from(element.querySelectorAll(".item")); expect(values.map((value) => value.style.top)).toEqual([ - "200px", - "266px", - "332px", - "398px", - "464px", - "530px", + "260px", + "340px", + "380px", + "420px", + "460px", + "525px", ]); expect(values.map((value) => value.style.left)).toEqual(Array(6).fill("310px")); expect(values.map((value) => value.textContent)).toEqual([ diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts index 07e5f46b..fd4cef7b 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts @@ -110,8 +110,7 @@ const PORTRAIT_FIELD_TOP = 883; const PORTRAIT_FIELD_ROW_GAP = 68; const PORTRAIT_RIGHT_FIRST_ROW_TOP_OFFSET = 0; const PORTRAIT_VALUE_VISUAL_OFFSET = -8; -const SOFIA_CERTIFICATE_FIELD_TOP = 200; -const SOFIA_CERTIFICATE_FIELD_ROW_GAP = 66; +const SOFIA_CERTIFICATE_FIELD_TOPS = [260, 340, 380, 420, 460, 525] as const; function trimText(value: unknown) { return String(value ?? "").trim(); @@ -526,7 +525,7 @@ function renderSofiaCertificate( color: "#000000", lineHeight: "32px", left: "310px", - top: `${SOFIA_CERTIFICATE_FIELD_TOP + index * SOFIA_CERTIFICATE_FIELD_ROW_GAP}px`, + top: `${SOFIA_CERTIFICATE_FIELD_TOPS[index]}px`, }); }); From d32988dde6b5802e114f86410a77c01bc6fe23ae Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 15:26:23 +0800 Subject: [PATCH 06/16] fix(barcode-print): normalize Sofia certificate row spacing --- .../barcodePrintCertificate.test.ts | 23 ++++--- .../barcode-print/barcodePrintCertificate.ts | 13 ++-- .../barcode-print/barcodePrintLodop.test.ts | 65 ++++++++++++++++++- .../intake/barcode-print/barcodePrintLodop.ts | 12 +++- .../barcodePrintSofiaCertificateLayout.ts | 23 +++++++ 5 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts index 674919cd..b91986b7 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts @@ -1,12 +1,16 @@ import { __barcodePrintCertificateTestUtils } from "./barcodePrintCertificate"; import { SOFIA_CARTON_BARCODE_QR_LAYOUT } from "./barcodePrintSofiaCartonLayout"; +import { + SOFIA_CERTIFICATE_FIELD_LINE_TOPS, + SOFIA_CERTIFICATE_FIELD_TOPS, +} from "./barcodePrintSofiaCertificateLayout"; describe("Sofia certificate field positioning", () => { afterEach(() => { jest.useRealTimers(); }); - it("uses the warehouse second-pass vertical positions without changing data or horizontal layout", () => { + it("uses the pre-printed line positions without changing data or horizontal layout", () => { jest.useFakeTimers().setSystemTime(new Date("2026-09-04T12:00:00.000Z")); const element = __barcodePrintCertificateTestUtils.renderSofiaCertificate( { @@ -27,14 +31,15 @@ describe("Sofia certificate field positioning", () => { ); const values = Array.from(element.querySelectorAll(".item")); - expect(values.map((value) => value.style.top)).toEqual([ - "260px", - "340px", - "380px", - "420px", - "460px", - "525px", - ]); + expect(values.map((value) => value.style.top)).toEqual( + SOFIA_CERTIFICATE_FIELD_TOPS.map((top) => `${top}px`), + ); + expect(SOFIA_CERTIFICATE_FIELD_LINE_TOPS).toEqual([260, 313, 366, 419, 472, 525]); + expect( + SOFIA_CERTIFICATE_FIELD_LINE_TOPS.slice(1).map( + (top, index) => top - SOFIA_CERTIFICATE_FIELD_LINE_TOPS[index], + ), + ).toEqual(Array(5).fill(53)); expect(values.map((value) => value.style.left)).toEqual(Array(6).fill("310px")); expect(values.map((value) => value.textContent)).toEqual([ "书架-经典北美黑胡桃色", diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts index fd4cef7b..1a676988 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts @@ -16,6 +16,12 @@ import { SOFIA_CARTON_CANVAS, SOFIA_CARTON_LINE_ART_LAYOUT, } from "./barcodePrintSofiaCartonLayout"; +import { + SOFIA_CERTIFICATE_FIELD_FONT_SIZE, + SOFIA_CERTIFICATE_FIELD_LEFT, + SOFIA_CERTIFICATE_FIELD_LINE_HEIGHT, + SOFIA_CERTIFICATE_FIELD_TOPS, +} from "./barcodePrintSofiaCertificateLayout"; import type { BarcodePrintRow } from "./barcodePrintRows"; type CertificateTemplate = { @@ -110,7 +116,6 @@ const PORTRAIT_FIELD_TOP = 883; const PORTRAIT_FIELD_ROW_GAP = 68; const PORTRAIT_RIGHT_FIRST_ROW_TOP_OFFSET = 0; const PORTRAIT_VALUE_VISUAL_OFFSET = -8; -const SOFIA_CERTIFICATE_FIELD_TOPS = [260, 340, 380, 420, 460, 525] as const; function trimText(value: unknown) { return String(value ?? "").trim(); @@ -521,10 +526,10 @@ function renderSofiaCertificate( ].forEach((text, index) => { appendItem(container, `item_${index + 1} item`, text, { position: "absolute", - fontSize: "26px", + fontSize: `${SOFIA_CERTIFICATE_FIELD_FONT_SIZE}px`, color: "#000000", - lineHeight: "32px", - left: "310px", + lineHeight: `${SOFIA_CERTIFICATE_FIELD_LINE_HEIGHT}px`, + left: `${SOFIA_CERTIFICATE_FIELD_LEFT}px`, top: `${SOFIA_CERTIFICATE_FIELD_TOPS[index]}px`, }); }); diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts index 0e38cf58..ba87ec58 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts @@ -1,5 +1,10 @@ import type { BarcodePrintRow } from "./barcodePrintRows"; -import { __barcodePrintTestUtils, printBarcodeRow } from "./barcodePrintLodop"; +import { + __barcodePrintTestUtils, + printBarcodeRow, +} from "./barcodePrintLodop"; +import { printCertificateRow } from "./barcodePrintLodop"; +import { SOFIA_CERTIFICATE_FIELD_TOPS } from "./barcodePrintSofiaCertificateLayout"; describe("splitBarcodeParts", () => { it("splits second-template barcodes at N suffix blocks", () => { @@ -164,3 +169,61 @@ describe("second barcode template", () => { ]); }); }); + +describe("Sofia certificate template", () => { + it("uses the same pre-printed line positions as the browser preview", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-09-04T12:00:00.000Z")); + const lodop = { + PRINT_INIT: jest.fn(), + SET_PRINT_PAGESIZE: jest.fn(), + NEWPAGE: jest.fn(), + ADD_PRINT_BARCODE: jest.fn(), + ADD_PRINT_TEXT: jest.fn(), + ADD_PRINT_IMAGE: jest.fn(), + SET_PRINT_STYLEA: jest.fn(), + PREVIEW: jest.fn(), + }; + const originalGetCLodop = window.getCLodop; + window.getCLodop = () => lodop as never; + + const row = { + barcode: "MKHDNZ04092607000240", + skuCode: "MKHDNZ04092607000240", + brandSkuCode: "", + factoryModel: "", + productCategory: "电脑桌", + color: "经典北美黑胡桃色++黑架", + size: "100/76/75", + specification: "100/76/75", + tagMaterial: "", + material: "", + executeStandard: "T/CZFA 0002-2025", + supplierName: "索菲亚", + } as BarcodePrintRow; + + try { + await printCertificateRow(row, "索菲亚合格证卡片(7.4cm*10cm)"); + } finally { + window.getCLodop = originalGetCLodop; + jest.useRealTimers(); + } + + const values = [ + "电脑桌-经典北美黑胡桃色++黑架", + "MKHDNZ04092607000240", + "100*76*75", + "T/CZFA 0002-2025", + "QC01", + "2026-08-25", + ]; + const textCalls = lodop.ADD_PRINT_TEXT.mock.calls.filter((call) => values.includes(call[4])); + + expect(textCalls).toHaveLength(values.length); + expect(textCalls.map((call) => call[0])).toEqual( + SOFIA_CERTIFICATE_FIELD_TOPS.map((top) => `${(top / 1181) * 100}mm`), + ); + expect(textCalls.map((call) => call[1])).toEqual( + SOFIA_CERTIFICATE_FIELD_TOPS.map(() => `${(310 / 874) * 74}mm`), + ); + }); +}); diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts index b8e20026..18aac025 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts @@ -3,6 +3,12 @@ import { pinyin } from "pinyin-pro"; import type { BarcodePrintRow } from "./barcodePrintRows"; import { splitGujiaBarcodeParts } from "./barcodePrintGujia"; +import { + SOFIA_CERTIFICATE_FIELD_FONT_SIZE, + SOFIA_CERTIFICATE_FIELD_LEFT, + SOFIA_CERTIFICATE_FIELD_LINE_HEIGHT, + SOFIA_CERTIFICATE_FIELD_TOPS, +} from "./barcodePrintSofiaCertificateLayout"; type LodopValue = string | number; @@ -721,9 +727,9 @@ function getCertificateCanvasTexts( if (template.layout === "sofiaCertificate") { return [productLabel, record.skuCode, specification, standard, "QC01", last10Day].map( (text, index) => - createCertificateText(text, 310, 398 + index * 66, { - fontSize: 26, - lineHeight: 32, + createCertificateText(text, SOFIA_CERTIFICATE_FIELD_LEFT, SOFIA_CERTIFICATE_FIELD_TOPS[index], { + fontSize: SOFIA_CERTIFICATE_FIELD_FONT_SIZE, + lineHeight: SOFIA_CERTIFICATE_FIELD_LINE_HEIGHT, }), ); } diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts new file mode 100644 index 00000000..2460771b --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts @@ -0,0 +1,23 @@ +/** + * Coordinates for the dynamic values printed over the pre-printed Sofia + * certificate card. These are canvas pixels on the 874 × 1181 template. + * + * Keep this as the single source of truth for both the browser preview and + * the Lodop print path; otherwise a print can silently fall back to a stale + * set of coordinates even when the preview looks correct. + */ +export const SOFIA_CERTIFICATE_FIELD_LEFT = 310 as const; +export const SOFIA_CERTIFICATE_FIELD_FONT_SIZE = 26 as const; +export const SOFIA_CERTIFICATE_FIELD_LINE_HEIGHT = 32 as const; + +/** The y-coordinate of each pre-printed underline in canvas pixels. */ +export const SOFIA_CERTIFICATE_FIELD_LINE_TOPS = [260, 313, 366, 419, 472, 525] as const; + +/** + * Dynamic values sit above the underline. The line-height is the text box + * height, so placing its bottom edge at the underline keeps the glyphs clear + * of the pre-printed stroke. + */ +export const SOFIA_CERTIFICATE_FIELD_TOPS = SOFIA_CERTIFICATE_FIELD_LINE_TOPS.map( + (lineTop) => lineTop - SOFIA_CERTIFICATE_FIELD_LINE_HEIGHT, +); From 4843e161243a114a954dab6595f85108e1b8f506 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 16:14:51 +0800 Subject: [PATCH 07/16] feat(sample-progress): add daily due-date reminder notifications --- .../sample-progress/reminders/run/route.ts | 29 ++ .../sample-progress/reminders/test/route.ts | 20 + .../sample-progress-reminder.service.test.ts | 181 ++++++++ .../sample-progress-reminder.service.ts | 419 ++++++++++++++++++ 4 files changed, 649 insertions(+) create mode 100644 furniture-3d-system/src/app/api/intake/sample-progress/reminders/run/route.ts create mode 100644 furniture-3d-system/src/app/api/intake/sample-progress/reminders/test/route.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/sample-progress-reminder.service.test.ts create mode 100644 furniture-3d-system/src/server/services/sample-progress-reminder.service.ts diff --git a/furniture-3d-system/src/app/api/intake/sample-progress/reminders/run/route.ts b/furniture-3d-system/src/app/api/intake/sample-progress/reminders/run/route.ts new file mode 100644 index 00000000..49750984 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/sample-progress/reminders/run/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { ErrorCode } from '@/server/core/errors' +import { SampleProgressReminderService } from '@/server/services/sample-progress-reminder.service' + +async function runScan() { + const service = new SampleProgressReminderService() + const result = await service.scanAndNotify() + return jsonSuccess(result, 200) +} + +// 定时进程:携带 worker 密钥触发;管理员(徐霞客):会话触发 +export async function POST(request: NextRequest) { + const expectedSecret = process.env.BOM_COSTING_WORKER_SECRET || '' + const providedSecret = request.headers.get('x-worker-secret') || '' + if (expectedSecret && providedSecret === expectedSecret) { + return runScan() + } + return handleRequest( + request, + async (ctx) => { + if (ctx.user?.name !== '徐霞客') { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '仅徐霞客可手动触发扫描', 403, ctx.requestId) + } + return runScan() + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/intake/sample-progress/reminders/test/route.ts b/furniture-3d-system/src/app/api/intake/sample-progress/reminders/test/route.ts new file mode 100644 index 00000000..acdf0011 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/sample-progress/reminders/test/route.ts @@ -0,0 +1,20 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { ErrorCode } from '@/server/core/errors' +import { SampleProgressReminderService } from '@/server/services/sample-progress-reminder.service' + +// 测试入口:把「未填交期 / 3天内到期 / 已超期」三种提醒的测试消息发给徐霞客 +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + if (ctx.user?.name !== '徐霞客') { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '仅徐霞客可触发测试消息', 403, ctx.requestId) + } + const service = new SampleProgressReminderService() + const results = await service.sendTestNotifications('徐霞客') + return jsonSuccess(results, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/server/services/__tests__/sample-progress-reminder.service.test.ts b/furniture-3d-system/src/server/services/__tests__/sample-progress-reminder.service.test.ts new file mode 100644 index 00000000..55f0882b --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/sample-progress-reminder.service.test.ts @@ -0,0 +1,181 @@ +import { SampleProgressReminderService, type SampleProgressReminderDeps, type SampleProgressItemRow } from '@/server/services/sample-progress-reminder.service' + +type SentRecord = { key: string; recipientUserId: string; title: string; content: string } + +const FIXED_NOW = new Date('2026-09-04T05:30:00Z') // 北京时间 13:30 + +function supplierRow(overrides: Partial): SampleProgressItemRow['suppliers'][number] { + return { + id: 'sup-1', + supplier: '测试供应商', + sampleDueDate: null, + status: 'BARE_WOOD', + ...overrides, + } +} + +function itemRow(suppliers: SampleProgressItemRow['suppliers'], overrides: Partial = {}): SampleProgressItemRow { + return { + id: 'item-1', + openingCode: 'KA-001', + flowStatus: '进行中', + status: 'BARE_WOOD', + merchandiserOwner: '徐霞客', + suppliers, + createdAt: new Date('2026-09-01T00:00:00Z'), + ...overrides, + } +} + +function baseDeps() { + const sent: SentRecord[] = [] + const undeliverable: string[] = [] + const items: SampleProgressItemRow[] = [] + const users = [ + { id: 'user-xu', name: '徐霞客', status: 'ACTIVE', dingtalkUserId: 'ding-xu', email: null, businessRoles: [] }, + ] + const deps: SampleProgressReminderDeps = { + listItems: async () => items, + findUsersByNames: async (names) => names.map((name) => users.find((u) => u.name === name) || { + id: 'user-' + name, + name, + status: 'INACTIVE', + dingtalkUserId: null, + email: '', + businessRoles: [], + }), + sendToEmployee: async (input) => { + sent.push({ key: input.idempotencyKey, recipientUserId: input.recipientUserId, title: input.title, content: input.content }) + return { id: 'delivery-' + sent.length } + }, + recordUndeliverable: async (input) => { + undeliverable.push(input.recipient.name) + return null + }, + } + return { + deps, + items, + sent, + undeliverable, + addItem: (row: SampleProgressItemRow) => { items.push(row) }, + } +} + +function serviceWith(deps: ReturnType['deps'], now = FIXED_NOW) { + return new SampleProgressReminderService(deps, () => now) +} + +describe('SampleProgressReminderService', () => { + it('为三类场景分别发送提醒:未填交期 / 3天内到期 / 已超期', async () => { + const env = baseDeps() + env.addItem(itemRow([supplierRow({ id: 'sup-missing', sampleDueDate: null })])) + env.addItem(itemRow([supplierRow({ id: 'sup-soon', sampleDueDate: new Date('2026-09-06T00:00:00Z') })])) // 距 9/4 还有 2 天 + env.addItem(itemRow([supplierRow({ id: 'sup-overdue', sampleDueDate: new Date('2026-08-25T00:00:00Z') })])) // 已超期 + const service = serviceWith(env.deps) + + const result = await service.scanAndNotify() + + expect(result.missingCount).toBe(1) + expect(result.dueSoonCount).toBe(1) + expect(result.overdueCount).toBe(1) + expect(env.sent).toHaveLength(3) + const titles = env.sent.map((s) => s.title) + expect(titles.some((t) => t.startsWith('打样进度未填写:KA-001'))).toBe(true) + expect(titles.some((t) => t.startsWith('打样任务即将到期:KA-001'))).toBe(true) + expect(titles.some((t) => t.startsWith('打样任务已超期:KA-001'))).toBe(true) + // 幂等键按 类别+供应商+交期日期 稳定生成 + expect(env.sent.every((s) => s.key.startsWith('sample-progress-reminder:'))).toBe(true) + }) + + it('消息正文按模板逐行渲染(字段清单 + 尾行动词)', async () => { + const env = baseDeps() + env.addItem(itemRow([supplierRow({ id: 'sup-soon', supplier: '贝格迪派', sampleDueDate: new Date('2026-09-05T00:00:00Z') })])) // 明天到期 + const service = serviceWith(env.deps) + + await service.scanAndNotify() + + expect(env.sent).toHaveLength(1) + expect(env.sent[0].title).toBe('打样任务即将到期:KA-001') + expect(env.sent[0].content).toBe([ + '监控对象:打样进度明细', + '开款编号:KA-001', + '工厂:贝格迪派', + '异常级别:预警', + '提醒时间:2026年9月4日 13:30', + '任务负责人:徐霞客', + '当前进度:白茬', + '目标完成日:2026-09-05', + '任务将在明天(2026-09-05)到期,请及时完成或更新实际排期。', + ].join('\n\n')) + }) + + it('超期消息带真实超期天数,未填交期场景按未填写口径渲染', async () => { + const env = baseDeps() + env.addItem(itemRow([supplierRow({ id: 'sup-overdue', sampleDueDate: new Date('2026-08-25T00:00:00Z') })])) + env.addItem(itemRow([supplierRow({ id: 'sup-missing', sampleDueDate: null })])) + const service = serviceWith(env.deps) + + await service.scanAndNotify() + + const overdue = env.sent.find((s) => s.title.startsWith('打样任务已超期')) + expect(overdue?.content).toContain('异常级别:超期') + expect(overdue?.content).toContain('目标完成日:2026-08-25') + expect(overdue?.content).toContain('任务已在(2026-08-25)到期,已超期 10 天,请及时跟进并填写超期原因。') + const missing = env.sent.find((s) => s.title.startsWith('打样进度未填写')) + expect(missing?.content).toContain('异常级别:未填写打样进度') + expect(missing?.content).toContain('当前进度:未填写打样进度') + expect(missing?.content).toContain('目标完成日:未填写打样进度') + expect(missing?.content).toContain('尚未填写打样交期,请及时在打样进度明细中补充。') + }) + + it('多条超期按打样单分开发送,互不合并', async () => { + const env = baseDeps() + env.addItem(itemRow([supplierRow({ id: 'sup-a', sampleDueDate: new Date('2026-08-25T00:00:00Z') })])) + env.addItem(itemRow([supplierRow({ id: 'sup-b', sampleDueDate: new Date('2026-08-20T00:00:00Z') })], { openingCode: 'KA-002', id: 'item-2' })) + const service = serviceWith(env.deps) + + const result = await service.scanAndNotify() + + expect(result.overdueCount).toBe(2) + const overdueMessages = env.sent.filter((s) => s.title.startsWith('打样任务已超期')) + expect(overdueMessages).toHaveLength(2) + expect(overdueMessages.map((s) => s.title)).toEqual(['打样任务已超期:KA-001', '打样任务已超期:KA-002']) + expect(new Set(overdueMessages.map((s) => s.key)).size).toBe(2) + }) + + it('已完成与已终止的供应商不产生提醒', async () => { + const env = baseDeps() + env.addItem(itemRow([ + supplierRow({ id: 'sup-done', sampleDueDate: new Date('2026-08-20T00:00:00Z'), status: 'COMPLETED' }), + supplierRow({ id: 'sup-term', sampleDueDate: null, status: 'TERMINATED' }), + ])) + const service = serviceWith(env.deps) + + const result = await service.scanAndNotify() + + expect(result.results).toHaveLength(0) + }) + + it('重复扫描同一天时幂等键稳定(由通知中心去重)', async () => { + const env = baseDeps() + env.addItem(itemRow([supplierRow({ id: 'sup-overdue', sampleDueDate: new Date('2026-08-25T00:00:00Z') })])) + const service = serviceWith(env.deps) + + await service.scanAndNotify() + const firstKeys = env.sent.map((s) => s.key) + await service.scanAndNotify() + const secondKeys = env.sent.slice(firstKeys.length).map((s) => s.key) + + expect(secondKeys).toEqual(firstKeys) + }) + + it('已拒绝的打样单不产生提醒', async () => { + const env = baseDeps() + env.addItem(itemRow([supplierRow({ id: 'sup-x', sampleDueDate: new Date('2026-08-20T00:00:00Z') })], { flowStatus: '已拒绝' })) + const service = serviceWith(env.deps) + + const result = await service.scanAndNotify() + expect(result.results).toHaveLength(0) + }) +}) diff --git a/furniture-3d-system/src/server/services/sample-progress-reminder.service.ts b/furniture-3d-system/src/server/services/sample-progress-reminder.service.ts new file mode 100644 index 00000000..8d0ea456 --- /dev/null +++ b/furniture-3d-system/src/server/services/sample-progress-reminder.service.ts @@ -0,0 +1,419 @@ +import { sampleProgressRepository } from '@/server/repositories/sample-progress.repository' +import { storeAccountNotificationRepository } from '@/server/repositories/store-account-notification.repository' +import { SAMPLE_PROGRESS_STATUS_LABELS } from '@/lib/sample-progress' +import { + notificationDeliveryService, + NotificationDeliveryError, +} from './notification-delivery.service' + +export type SampleProgressReminderCategory = 'missing-due-date' | 'due-soon' | 'overdue' + +export type SampleProgressSupplierRow = { + id: string + supplier: string + sampleDueDate: Date | string | null + status: string +} + +export type SampleProgressItemRow = { + id: string + openingCode: string + flowStatus: string + status: string + merchandiserOwner: string | null + suppliers?: SampleProgressSupplierRow[] + createdAt: Date +} + +export type SampleProgressSystemUser = { + id: string + name: string + status?: string + dingtalkUserId?: string | null + email?: string | null + businessRoles?: unknown[] +} + +export type SampleProgressReminderDeps = { + listItems(): Promise + findUsersByNames(names: string[]): Promise + sendToEmployee(input: { + idempotencyKey: string + businessEvent: string + businessRecordId?: string + source: 'USER' | 'SYSTEM' + recipientUserId: string + title: string + content: string + retryExisting: boolean + }): Promise<{ id: string }> + recordUndeliverable(input: { + idempotencyKey: string + businessEvent: string + businessRecordId?: string + source: 'USER' | 'SYSTEM' + recipient: { + id: string + name: string + email: string + status: string + businessRoles: unknown[] + dingtalkUserId: string + } + title: string + content: string + errorCode: string + errorMessage: string + }): Promise +} + +export type SampleProgressReminderTarget = { + supplierId: string + itemId: string + openingCode: string + supplierName: string + merchandiserOwner: string | null + sampleDueDate: string | null + daysOverdue: number | null + daysUntilDue: number | null + category: SampleProgressReminderCategory + statusLabel: string +} + +export type SampleProgressReminderResult = { + category: SampleProgressReminderCategory + recipientName: string + recipientUserId: string | null + status: 'sent' | 'failed' | 'undeliverable' + errorCode: string | null + errorMessage: string | null + idempotencyKey: string +} + +const DAY_MS = 86_400_000 +const EXCLUDED_SUPPLIER_STATUSES = new Set(['COMPLETED', 'TERMINATED']) +const EXCLUDED_FLOW_STATUSES = new Set(['已终止', '已拒绝']) + +function toDateOnly(value: Date | string | null | undefined) { + if (!value) return null + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10) +} + +function daysBetween(fromIso: string, toIso: string) { + return Math.round( + (new Date(`${toIso}T00:00:00Z`).getTime() - new Date(`${fromIso}T00:00:00Z`).getTime()) / DAY_MS, + ) +} + +const REMINDER_LEVEL_LABELS: Record = { + 'missing-due-date': '未填写打样进度', + 'due-soon': '预警', + overdue: '超期', +} + +function formatRemindAt(date: Date) { + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${hh}:${mm}` +} + +function describeReminder(input: { + category: SampleProgressReminderCategory + openingCode: string + supplierName: string + ownerName: string + statusLabel: string + dueDate: string | null + days: number | null + remindAt: Date +}) { + const level = REMINDER_LEVEL_LABELS[input.category] + const title = + input.category === 'missing-due-date' + ? `打样进度未填写:${input.openingCode}` + : input.category === 'due-soon' + ? `打样任务即将到期:${input.openingCode}` + : `打样任务已超期:${input.openingCode}` + const currentProgress = input.category === 'missing-due-date' ? '未填写打样进度' : input.statusLabel + const targetDate = input.category === 'missing-due-date' ? '未填写打样进度' : input.dueDate + let trailing: string + if (input.category === 'missing-due-date') { + trailing = '尚未填写打样交期,请及时在打样进度明细中补充。' + } else if (input.category === 'due-soon') { + const relative = input.days === 0 ? '今天' : input.days === 1 ? '明天' : `${input.days ?? 0} 天后` + trailing = `任务将在${relative}(${input.dueDate})到期,请及时完成或更新实际排期。` + } else { + trailing = `任务已在(${input.dueDate})到期,已超期 ${Math.abs(input.days ?? 0)} 天,请及时跟进并填写超期原因。` + } + const content = [ + '监控对象:打样进度明细', + `开款编号:${input.openingCode}`, + `工厂:${input.supplierName}`, + `异常级别:${level}`, + `提醒时间:${formatRemindAt(input.remindAt)}`, + `任务负责人:${input.ownerName || '未填写'}`, + `当前进度:${currentProgress}`, + `目标完成日:${targetDate}`, + trailing, + ].join('\n\n') + return { title, content } +} + +function statusLabelOf(status: string | null | undefined) { + if (!status) return '未填写' + return SAMPLE_PROGRESS_STATUS_LABELS[status as keyof typeof SAMPLE_PROGRESS_STATUS_LABELS] || status +} + +function buildIdempotencyKey( + category: SampleProgressReminderCategory, + supplierId: string, + dueDate: string | null, + today: string, +) { + if (category === 'missing-due-date') return `sample-progress-reminder:missing:${supplierId}` + if (category === 'due-soon') return `sample-progress-reminder:due-soon:${supplierId}:${dueDate}` + return `sample-progress-reminder:overdue:${supplierId}:${today}` +} + +export class SampleProgressReminderService { + constructor( + private readonly deps: SampleProgressReminderDeps = { + listItems: () => sampleProgressRepository.list(), + findUsersByNames: (names) => storeAccountNotificationRepository.findUsersByNames(names), + sendToEmployee: (input) => notificationDeliveryService.sendToEmployee(input), + recordUndeliverable: (input) => notificationDeliveryService.recordUndeliverable(input), + }, + private readonly now: () => Date = () => new Date(), + ) {} + + async scanAndNotify(): Promise<{ + missingCount: number + dueSoonCount: number + overdueCount: number + results: SampleProgressReminderResult[] + }> { + const now = this.now() + const today = now.toISOString().slice(0, 10) + const items = await this.deps.listItems() + const targets: SampleProgressReminderTarget[] = [] + + for (const item of items) { + if (EXCLUDED_FLOW_STATUSES.has(item.flowStatus || '')) continue + for (const supplier of item.suppliers ?? []) { + if (EXCLUDED_SUPPLIER_STATUSES.has(supplier.status || '')) continue + const dueDate = toDateOnly(supplier.sampleDueDate) + let category: SampleProgressReminderCategory | null = null + let daysUntilDue: number | null = null + let daysOverdue: number | null = null + if (!dueDate) { + category = 'missing-due-date' + } else { + const diff = daysBetween(today, dueDate) + if (diff < 0) { + category = 'overdue' + daysOverdue = Math.abs(diff) + } else if (diff <= 3) { + category = 'due-soon' + daysUntilDue = diff + } + } + if (!category) continue + targets.push({ + supplierId: supplier.id, + itemId: item.id, + openingCode: item.openingCode, + supplierName: supplier.supplier || '(未填写供应商)', + merchandiserOwner: item.merchandiserOwner || null, + sampleDueDate: dueDate, + daysOverdue, + daysUntilDue, + category, + statusLabel: statusLabelOf(supplier.status), + }) + } + } + + const userCache = new Map() + const results: SampleProgressReminderResult[] = [] + for (const target of targets) { + const ownerName = target.merchandiserOwner || '' + let user = userCache.get(ownerName) ?? null + if (!userCache.has(ownerName)) { + const users = await this.deps.findUsersByNames([ownerName].filter(Boolean)) + user = users[0] ?? null + userCache.set(ownerName, user) + } + const { title, content } = describeReminder({ + category: target.category, + openingCode: target.openingCode, + supplierName: target.supplierName, + ownerName: target.merchandiserOwner || '', + statusLabel: target.statusLabel, + dueDate: target.sampleDueDate, + days: target.category === 'due-soon' ? target.daysUntilDue : target.daysOverdue, + remindAt: now, + }) + const idempotencyKey = buildIdempotencyKey( + target.category, + target.supplierId, + target.sampleDueDate, + today, + ) + if (!user) { + try { + await this.deps.recordUndeliverable({ + idempotencyKey: buildIdempotencyKey(target.category, target.supplierId, target.sampleDueDate, today), + businessEvent: 'sample_progress_reminder', + businessRecordId: target.supplierId, + source: 'SYSTEM', + recipient: { + id: ownerName || target.supplierId, + name: ownerName || '未分配跟单负责人', + email: '', + status: 'INACTIVE', + businessRoles: [], + dingtalkUserId: '', + }, + title, + content, + errorCode: 'RECIPIENT_NOT_FOUND', + errorMessage: `按跟单负责人姓名 ${ownerName || '(未填写)'} 未匹配到系统用户`, + }) + results.push({ + category: target.category, + recipientName: ownerName || '(未填写)', + recipientUserId: null, + status: 'undeliverable', + errorCode: 'RECIPIENT_NOT_FOUND', + errorMessage: '未匹配到系统用户', + idempotencyKey, + }) + } catch (error) { + results.push({ + category: target.category, + recipientName: ownerName || '(未填写)', + recipientUserId: null, + status: 'failed', + errorCode: 'RECORD_UNDELIVERABLE_FAILED', + errorMessage: error instanceof Error ? error.message : String(error), + idempotencyKey, + }) + } + continue + } + try { + await this.deps.sendToEmployee({ + idempotencyKey, + businessEvent: 'sample_progress_reminder', + businessRecordId: target.supplierId, + source: 'SYSTEM', + recipientUserId: user.id, + title, + content, + retryExisting: false, + }) + results.push({ + category: target.category, + recipientName: user.name, + recipientUserId: user.id, + status: 'sent', + errorCode: null, + errorMessage: null, + idempotencyKey, + }) + } catch (error) { + const deliveryError = error as NotificationDeliveryError + results.push({ + category: target.category, + recipientName: user.name, + recipientUserId: user.id, + status: 'failed', + errorCode: deliveryError.code || 'SEND_FAILED', + errorMessage: deliveryError.message, + idempotencyKey, + }) + } + } + + return { + missingCount: targets.filter(t => t.category === 'missing-due-date').length, + dueSoonCount: targets.filter(t => t.category === 'due-soon').length, + overdueCount: targets.filter(t => t.category === 'overdue').length, + results, + } + } + + /** 测试入口:未填交期 / 预警 / 超期×2(模拟多条超期分开发)全部发给指定用户(如 徐霞客)。 */ + async sendTestNotifications(recipientName: string): Promise { + const now = this.now() + const users = await this.deps.findUsersByNames([recipientName]) + const recipient = users[0] || null + if (!recipient) throw new NotificationDeliveryError(`未找到系统用户:${recipientName}`, 'RECIPIENT_NOT_FOUND') + + const dueSoonDate = new Date(now.getTime() + 1 * DAY_MS).toISOString().slice(0, 10) + const overdueDate = new Date(now.getTime() - 3 * DAY_MS).toISOString().slice(0, 10) + const overdueDate2 = new Date(now.getTime() - 5 * DAY_MS).toISOString().slice(0, 10) + const demos: Array<{ + category: SampleProgressReminderCategory + openingCode: string + supplier: string + statusLabel: string + dueDate: string | null + days: number | null + }> = [ + { category: 'missing-due-date', openingCode: 'XP2026062500000226', supplier: '贝格迪派', statusLabel: '未填写打样进度', dueDate: null, days: null }, + { category: 'due-soon', openingCode: 'XP2026062500000226', supplier: '贝格迪派', statusLabel: '白茬', dueDate: dueSoonDate, days: 1 }, + { category: 'overdue', openingCode: 'XP2026062500000226', supplier: '贝格迪派', statusLabel: '白茬', dueDate: overdueDate, days: 3 }, + { category: 'overdue', openingCode: 'XP2026061200000142', supplier: '瑞敏家居', statusLabel: '油漆', dueDate: overdueDate2, days: 5 }, + ] + + const results: SampleProgressReminderResult[] = [] + for (const [index, demo] of demos.entries()) { + const { title, content } = describeReminder({ + category: demo.category, + openingCode: demo.openingCode, + supplierName: demo.supplier, + ownerName: recipientName, + statusLabel: demo.statusLabel, + dueDate: demo.dueDate, + days: demo.days, + remindAt: now, + }) + const idempotencyKey = `sample-progress-reminder-test:${demo.category}:${index}:${now.getTime()}` + try { + await this.deps.sendToEmployee({ + idempotencyKey, + businessEvent: 'sample_progress_reminder_test', + businessRecordId: undefined, + source: 'SYSTEM', + recipientUserId: recipient.id, + title: `(测试)${title}`, + content, + retryExisting: false, + }) + results.push({ + category: demo.category, + recipientName: recipientName, + recipientUserId: recipient.id, + status: 'sent', + errorCode: null, + errorMessage: null, + idempotencyKey, + }) + } catch (error) { + const deliveryError = error as NotificationDeliveryError + results.push({ + category: demo.category, + recipientName: recipientName, + recipientUserId: recipient.id, + status: 'failed', + errorCode: deliveryError.code || 'SEND_FAILED', + errorMessage: deliveryError.message, + idempotencyKey, + }) + } + } + return results + } +} From f4712cd1a286a657eab75db25eba54d06051b5b6 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 17:39:35 +0800 Subject: [PATCH 08/16] feat(intake): validate sku import skuCode spu prefix standard sku workbook import now rejects rows whose skuCode does not start with the product spu in uppercase (lowercase codes are reported as needing uppercase); rows with an empty skuCode still pass and get auto-generated on save; the whole import is rejected with per-row line numbers and reasons. barcodes are skipped since they derive from the sku code. --- .../basic-info/useSkuWorkbookController.ts | 16 ++++- .../src/lib/non-integrated-sku-export.test.ts | 59 +++++++++++++++++++ .../src/lib/non-integrated-sku-export.ts | 30 +++++++++- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuWorkbookController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuWorkbookController.ts index ddaaf4b3..322bcb79 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuWorkbookController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuWorkbookController.ts @@ -4,7 +4,10 @@ import { type SetStateAction, } from "react"; -import { parseNonIntegratedSkuWorkbook } from "@/lib/non-integrated-sku-export"; +import { + findSkuImportSpuPrefixErrors, + parseNonIntegratedSkuWorkbook, +} from "@/lib/non-integrated-sku-export"; import type { NonIntegratedSkuItem } from "../../nonIntegratedStorage"; import type { @@ -154,6 +157,17 @@ export function useSkuWorkbookController({ if (rows.length === 0) { throw new Error("上传资料中没有可导入的 SKU 行"); } + if (mode === "standard") { + const spuPrefixErrors = findSkuImportSpuPrefixErrors( + rows, + formValues.spu, + ); + if (spuPrefixErrors.length > 0) { + throw new Error( + `导入已取消:货号/条码必须以 ${formValues.spu} 开头。${spuPrefixErrors.join(" ")}`, + ); + } + } if (mode === "fuzzy") { const supplierErrors = getFuzzyImportSupplierErrors( rows, diff --git a/furniture-3d-system/src/lib/non-integrated-sku-export.test.ts b/furniture-3d-system/src/lib/non-integrated-sku-export.test.ts index 5ae6f039..70444eb2 100644 --- a/furniture-3d-system/src/lib/non-integrated-sku-export.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-sku-export.test.ts @@ -1,6 +1,7 @@ import * as XLSX from "xlsx"; import { + findSkuImportSpuPrefixErrors, NON_INTEGRATED_SKU_TEMPLATE_COLUMNS, parseNonIntegratedSkuWorkbook, } from "@/lib/non-integrated-sku-export"; @@ -109,3 +110,61 @@ describe("parseNonIntegratedSkuWorkbook", () => { ); }); }); + +describe("findSkuImportSpuPrefixErrors", () => { + it("rejects non-empty sku codes that miss the spu prefix", () => { + const errors = findSkuImportSpuPrefixErrors( + [ + { + skuCode: "LRSF009601", + barcode: "LRSF009601V01", + rowNumber: 2, + }, + ], + "LRSF0095", + ); + + expect(errors).toEqual([ + "第2行:货号「LRSF009601」未以 LRSF0095 开头", + ]); + }); + + it("ignores barcodes entirely since they are auto-derived from the sku code", () => { + const errors = findSkuImportSpuPrefixErrors( + [{ skuCode: "LRSF009501", barcode: "WHATEVER-BARCODE", rowNumber: 2 }], + "LRSF0095", + ); + + expect(errors).toEqual([]); + }); + + it("requires uppercase and rejects lowercase sku codes", () => { + const errors = findSkuImportSpuPrefixErrors( + [{ skuCode: "lrsf009501", barcode: "LRSF009501V01", rowNumber: 2 }], + "LRSF0095", + ); + + expect(errors).toEqual(["第2行:货号「lrsf009501」需为大写"]); + }); + + it("skips validation when the spu is empty", () => { + const errors = findSkuImportSpuPrefixErrors( + [{ skuCode: "ANY", barcode: "ANY01", rowNumber: 2 }], + "", + ); + + expect(errors).toEqual([]); + }); + + it("falls back to positional row labels when rowNumber is missing", () => { + const errors = findSkuImportSpuPrefixErrors( + [ + { skuCode: "", barcode: "" }, + { skuCode: "OTHER01", barcode: "" }, + ], + "LRSF0095", + ); + + expect(errors).toEqual(["第3行:货号「OTHER01」未以 LRSF0095 开头"]); + }); +}); diff --git a/furniture-3d-system/src/lib/non-integrated-sku-export.ts b/furniture-3d-system/src/lib/non-integrated-sku-export.ts index 67f72316..1361955e 100644 --- a/furniture-3d-system/src/lib/non-integrated-sku-export.ts +++ b/furniture-3d-system/src/lib/non-integrated-sku-export.ts @@ -24,6 +24,33 @@ export type NonIntegratedSkuExportRow = { batchNo: string batchChangeType: string dingTalkFlowNo: string + rowNumber?: number +} + +export function findSkuImportSpuPrefixErrors( + rows: ReadonlyArray< + Pick & { rowNumber?: number } + >, + spu: string, +) { + const normalizedSpu = spu.trim().toUpperCase() + if (!normalizedSpu) return [] + + const errors: string[] = [] + rows.forEach((row, index) => { + const rowLabel = row.rowNumber ?? index + 2 + const skuCode = row.skuCode.trim() + if (!skuCode || skuCode.startsWith(normalizedSpu)) return + + if (skuCode.toUpperCase().startsWith(normalizedSpu)) { + errors.push(`第${rowLabel}行:货号「${skuCode}」需为大写`) + } else { + errors.push( + `第${rowLabel}行:货号「${skuCode}」未以 ${normalizedSpu} 开头`, + ) + } + }) + return errors } export type NonIntegratedSkuFuzzyImportRow = { @@ -340,7 +367,7 @@ export async function parseNonIntegratedSkuWorkbook( if (missingLabels.length === 0) { const rows = sheetRows .slice(headerRowIndex + 1) - .map((row) => { + .map((row, index) => { const importRow = createEmptyImportRow() for (const column of NON_INTEGRATED_SKU_TEMPLATE_COLUMNS) { const cellIndex = headerIndexByLabel.get(column.label) @@ -349,6 +376,7 @@ export async function parseNonIntegratedSkuWorkbook( ? '' : normalizeWorkbookCellValue(row[cellIndex]) } + importRow.rowNumber = headerRowIndex + index + 2 return importRow }) .filter((row) => From 4f6510271f9440bce460d633156642d357d40881 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 17:49:31 +0800 Subject: [PATCH 09/16] fix(intake): replace unsafe cast in sku export empty row builder adding the optional rowNumber field to NonIntegratedSkuExportRow made the Object.fromEntries cast insufficient for the production build type check; build the empty import row explicitly instead. --- .../src/lib/non-integrated-sku-export.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/furniture-3d-system/src/lib/non-integrated-sku-export.ts b/furniture-3d-system/src/lib/non-integrated-sku-export.ts index 1361955e..f75a8af6 100644 --- a/furniture-3d-system/src/lib/non-integrated-sku-export.ts +++ b/furniture-3d-system/src/lib/non-integrated-sku-export.ts @@ -309,9 +309,31 @@ function getFuzzyImportColumnKey(value: string) { } function createEmptyImportRow(): NonIntegratedSkuExportRow { - return Object.fromEntries( - NON_INTEGRATED_SKU_TEMPLATE_COLUMNS.map((column) => [column.key, '']), - ) as NonIntegratedSkuExportRow + return { + spu: '', + model: '', + skuCode: '', + barcode: '', + setCount: '', + specName: '', + size: '', + color: '', + material: '', + boardMaterialType: '', + versionNo: '', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + nationalStandardCompliant: '', + manufacturer: '', + manufacturerCode: '', + minimumOrderQuantity: '', + salesStatus: '', + batchNo: '', + batchChangeType: '', + dingTalkFlowNo: '', + } } export async function parseNonIntegratedSkuWorkbook( From 2dbb2140068fedd188df527f1c4620fe5ea1187b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 4 Sep 2026 17:59:40 +0800 Subject: [PATCH 10/16] fix(intake): move import rowNumber off the shared sku export row type the optional numeric rowNumber on NonIntegratedSkuExportRow broke the dynamic template column writes (string vs never) in the production build type check; import rows now use a dedicated NonIntegratedSkuImportRow type that extends the export row with a required rowNumber. --- .../src/lib/non-integrated-sku-export.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/furniture-3d-system/src/lib/non-integrated-sku-export.ts b/furniture-3d-system/src/lib/non-integrated-sku-export.ts index f75a8af6..60a20baa 100644 --- a/furniture-3d-system/src/lib/non-integrated-sku-export.ts +++ b/furniture-3d-system/src/lib/non-integrated-sku-export.ts @@ -24,12 +24,15 @@ export type NonIntegratedSkuExportRow = { batchNo: string batchChangeType: string dingTalkFlowNo: string - rowNumber?: number +} + +export type NonIntegratedSkuImportRow = NonIntegratedSkuExportRow & { + rowNumber: number } export function findSkuImportSpuPrefixErrors( rows: ReadonlyArray< - Pick & { rowNumber?: number } + Pick >, spu: string, ) { @@ -65,7 +68,7 @@ export type NonIntegratedSkuFuzzyImportRow = { export type NonIntegratedSkuWorkbookImport = | { mode: "standard" - rows: NonIntegratedSkuExportRow[] + rows: NonIntegratedSkuImportRow[] sheetName: string } | { @@ -389,7 +392,7 @@ export async function parseNonIntegratedSkuWorkbook( if (missingLabels.length === 0) { const rows = sheetRows .slice(headerRowIndex + 1) - .map((row, index) => { + .map((row, index): NonIntegratedSkuImportRow => { const importRow = createEmptyImportRow() for (const column of NON_INTEGRATED_SKU_TEMPLATE_COLUMNS) { const cellIndex = headerIndexByLabel.get(column.label) @@ -398,8 +401,7 @@ export async function parseNonIntegratedSkuWorkbook( ? '' : normalizeWorkbookCellValue(row[cellIndex]) } - importRow.rowNumber = headerRowIndex + index + 2 - return importRow + return { ...importRow, rowNumber: headerRowIndex + index + 2 } }) .filter((row) => NON_INTEGRATED_SKU_TEMPLATE_COLUMNS.some( From 4a574041a91a06718fa17cf5cf18c0b1c51ae282 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 5 Sep 2026 11:53:55 +0800 Subject: [PATCH 11/16] fix(barcode-print): restore Sofia certificate row pitch --- .../barcode-print/barcodePrintCertificate.test.ts | 4 ++-- .../barcodePrintSofiaCertificateLayout.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts index b91986b7..fd5f3ac7 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.test.ts @@ -34,12 +34,12 @@ describe("Sofia certificate field positioning", () => { expect(values.map((value) => value.style.top)).toEqual( SOFIA_CERTIFICATE_FIELD_TOPS.map((top) => `${top}px`), ); - expect(SOFIA_CERTIFICATE_FIELD_LINE_TOPS).toEqual([260, 313, 366, 419, 472, 525]); + expect(SOFIA_CERTIFICATE_FIELD_LINE_TOPS).toEqual([260, 326, 392, 458, 524, 590]); expect( SOFIA_CERTIFICATE_FIELD_LINE_TOPS.slice(1).map( (top, index) => top - SOFIA_CERTIFICATE_FIELD_LINE_TOPS[index], ), - ).toEqual(Array(5).fill(53)); + ).toEqual(Array(5).fill(66)); expect(values.map((value) => value.style.left)).toEqual(Array(6).fill("310px")); expect(values.map((value) => value.textContent)).toEqual([ "书架-经典北美黑胡桃色", diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts index 2460771b..74e9808c 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSofiaCertificateLayout.ts @@ -10,8 +10,14 @@ export const SOFIA_CERTIFICATE_FIELD_LEFT = 310 as const; export const SOFIA_CERTIFICATE_FIELD_FONT_SIZE = 26 as const; export const SOFIA_CERTIFICATE_FIELD_LINE_HEIGHT = 32 as const; -/** The y-coordinate of each pre-printed underline in canvas pixels. */ -export const SOFIA_CERTIFICATE_FIELD_LINE_TOPS = [260, 313, 366, 419, 472, 525] as const; +/** + * The y-coordinate of each pre-printed underline in canvas pixels. + * + * Warehouse print feedback confirmed the first row at 260px and a 66px + * physical row pitch. Keep the pitch intact so later values do not drift + * progressively above their pre-printed labels. + */ +export const SOFIA_CERTIFICATE_FIELD_LINE_TOPS = [260, 326, 392, 458, 524, 590] as const; /** * Dynamic values sit above the underline. The line-height is the text box From 8ea6a54eaa6b980b3bb0a84708e0cebf40467ce6 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 7 Sep 2026 11:15:01 +0800 Subject: [PATCH 12/16] feat(finished-product-progress): auto-import factory-confirmed SKUs on sample completion --- .../src/lib/finished-product-progress.ts | 7 +- ...nished-product-progress-list-order.test.ts | 66 ++++++ .../finished-product-progress.repository.ts | 55 ++++- ...hed-product-factory-intake.service.test.ts | 158 +++++++++++++ ...finished-product-factory-intake.service.ts | 207 ++++++++++++++++++ .../services/sample-progress.service.ts | 45 +++- 6 files changed, 531 insertions(+), 7 deletions(-) create mode 100644 furniture-3d-system/src/server/repositories/__tests__/finished-product-progress-list-order.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/finished-product-factory-intake.service.test.ts create mode 100644 furniture-3d-system/src/server/services/finished-product-factory-intake.service.ts diff --git a/furniture-3d-system/src/lib/finished-product-progress.ts b/furniture-3d-system/src/lib/finished-product-progress.ts index e5636989..39bdcb17 100644 --- a/furniture-3d-system/src/lib/finished-product-progress.ts +++ b/furniture-3d-system/src/lib/finished-product-progress.ts @@ -94,7 +94,12 @@ export function calculateFinishedProductInTransitQuantity(orderedQuantity: numbe return Math.trunc(orderedQuantity) - Math.trunc(deliveryQuantity) } -export const FINISHED_PRODUCT_CURRENT_NODE_OPTIONS = ['合同签订', '采购在途', '送货中', '已完结'] as const +export const FINISHED_PRODUCT_CURRENT_NODE_OPTIONS = ['合同待签', '合同签订', '采购在途', '送货中', '已完结'] as const + +/** 同一 SPU 基号聚组排序用:去掉货号前缀字母(GBSFY1234/GBPM1234/1234 → 1234),前缀不同不影响归组。 */ +export function finishedProductSpuBase(itemNumber: string) { + return String(itemNumber ?? '').trim().replace(/^[A-Za-z]+/, '') +} export function resolveFinishedProductCurrentNode(contractNumber: string, orderedQuantity: number, deliveryQuantity: number) { if (!String(contractNumber ?? '').trim()) return '合同签订' diff --git a/furniture-3d-system/src/server/repositories/__tests__/finished-product-progress-list-order.test.ts b/furniture-3d-system/src/server/repositories/__tests__/finished-product-progress-list-order.test.ts new file mode 100644 index 00000000..4a294703 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/finished-product-progress-list-order.test.ts @@ -0,0 +1,66 @@ +import { sortFinishedProductRowsForList } from '@/server/repositories/finished-product-progress.repository' + +type Row = { + id: string + orderPlacedDate: Date | null + orderSequenceNo: string + createdAt: Date + itemNumber: string + barcode: string | null +} + +function row(id: string, overrides: Partial = {}): Row { + return { + id, + orderPlacedDate: null, + orderSequenceNo: '', + createdAt: new Date('2026-09-01T00:00:00Z'), + itemNumber: id, + barcode: null, + ...overrides, + } +} + +describe('sortFinishedProductRowsForList(成品进度列表聚组排序)', () => { + it('有下单日期的订单在前且保持原序,无日期的按 SPU 基号聚组', () => { + const rows = [ + row('undated-gbsfy', { itemNumber: 'GBSFY1234' }), + row('dated-a', { orderPlacedDate: new Date('2026-09-01T00:00:00Z'), orderSequenceNo: 'SH003' }), + row('undated-plain', { itemNumber: '1234' }), + row('undated-gbpm', { itemNumber: 'GBPM1234' }), + row('dated-b', { orderPlacedDate: new Date('2026-08-01T00:00:00Z'), orderSequenceNo: 'SH002' }), + row('undated-other', { itemNumber: 'GBSFY999' }), + ] + + const sorted = sortFinishedProductRowsForList(rows) + + expect(sorted.map((r) => r.id)).toEqual([ + 'dated-a', // 有下单日期,最近的在前 + 'dated-b', + 'undated-other', // 基号 999 < 1234,组在前 + 'undated-plain', // 基号 1234 组:GBPM1234 / 1234 / GBSFY1234 按货号排序聚在一起 + 'undated-gbpm', + 'undated-gbsfy', + ]) + }) + + it('同组内条码作为次级排序,创建时间兜底', () => { + const rows = [ + row('x-b', { itemNumber: 'GBSFY1234', barcode: 'BBB' }), + row('x-a', { itemNumber: 'GBSFY1234', barcode: 'AAA' }), + row('x-c', { itemNumber: 'GBSFY1234', barcode: 'AAA', createdAt: new Date('2026-08-01T00:00:00Z') }), + ] + + const sorted = sortFinishedProductRowsForList(rows) + + expect(sorted.map((r) => r.id)).toEqual(['x-a', 'x-c', 'x-b']) + }) + + it('无非日期行时保持原序不变', () => { + const rows = [ + row('d1', { orderPlacedDate: new Date('2026-09-01T00:00:00Z') }), + row('d2', { orderPlacedDate: new Date('2026-08-01T00:00:00Z') }), + ] + expect(sortFinishedProductRowsForList(rows).map((r) => r.id)).toEqual(['d1', 'd2']) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts b/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts index 20bfba9c..c09e8518 100644 --- a/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts +++ b/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts @@ -4,6 +4,7 @@ import { FINISHED_PRODUCT_CURRENT_NODE_OPTIONS, buildOrderSequenceNo, buildOrderSequencePrefix, + finishedProductSpuBase, resolveFinishedProductCurrentNode, type FinishedProductProgressFilters, type FinishedProductProgressInput, @@ -104,10 +105,57 @@ function dataValues(input: FinishedProductProgressInput) { firstDueDate: input.firstDueDate ? new Date(`${input.firstDueDate}T00:00:00Z`) : null, orderDueDate: input.orderDueDate ? new Date(`${input.orderDueDate}T00:00:00Z`) : null, realtimeDate: input.realtimeDate ? new Date(`${input.realtimeDate}T00:00:00Z`) : null, - currentNode: resolveFinishedProductCurrentNode(input.contractNumber, input.orderedQuantity, input.deliveryQuantity), + // 有合同号的行按数量自动推进节点;空合同行(含定厂自动带入的待签行)保留表单所选节点 + currentNode: String(input.contractNumber ?? '').trim() + ? resolveFinishedProductCurrentNode(input.contractNumber, input.orderedQuantity, input.deliveryQuantity) + : (String(input.currentNode ?? '').trim() || '合同待签'), } } +type SortableFinishedProductRow = { + orderPlacedDate: Date | null + orderSequenceNo: string + createdAt: Date + itemNumber: string + barcode: string | null +} + +/** + * 列表排序:有下单日期的订单保持原序在前;无下单日期的行(合同待签等)按 SPU 基号聚组—— + * 同基号(GBSFY1234/GBPM1234/1234 → 1234)的行排在一起,组内按货号、条码排序。 + */ +export function sortFinishedProductRowsForList(rows: T[]): T[] { + const dated = rows.filter((row) => row.orderPlacedDate) + const undated = rows.filter((row) => !row.orderPlacedDate) + if (undated.length === 0) return rows + + const groups = new Map() + for (const row of undated) { + const base = finishedProductSpuBase(row.itemNumber) || row.itemNumber || '~' + const bucket = groups.get(base) + if (bucket) bucket.push(row) + else groups.set(base, [row]) + } + const compareBase = (left: string, right: string) => { + const leftNumeric = /^\d+$/.test(left) + const rightNumeric = /^\d+$/.test(right) + if (leftNumeric && rightNumeric) return Number(left) - Number(right) + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1 + return left.localeCompare(right, 'zh-Hans-CN') + } + const grouped = [...groups.entries()] + .sort(([left], [right]) => compareBase(left, right)) + .flatMap(([, bucket]) => bucket.sort((left, right) => { + const byItemNumber = left.itemNumber.localeCompare(right.itemNumber, 'zh-Hans-CN') + if (byItemNumber !== 0) return byItemNumber + const leftBarcode = left.barcode ?? '' + const rightBarcode = right.barcode ?? '' + if (leftBarcode !== rightBarcode) return leftBarcode.localeCompare(rightBarcode) + return right.createdAt.getTime() - left.createdAt.getTime() + })) + return [...dated, ...grouped] +} + export class FinishedProductProgressRepository { private async nextOrderSequenceNoInTransaction(tx: Prisma.TransactionClient, prefix: string) { const normalized = prefix.trim() || 'DD' @@ -136,10 +184,11 @@ export class FinishedProductProgressRepository { ], }) // 行身份重设计后每条记录即一笔独立订单,平铺返回 - const pagedRows = rows.slice(params.skip, params.skip + params.take) + const sortedRows = sortFinishedProductRowsForList(rows) + const pagedRows = sortedRows.slice(params.skip, params.skip + params.take) return { groups: pagedRows.map((row) => [row]), - total: rows.length, + total: sortedRows.length, } } diff --git a/furniture-3d-system/src/server/services/__tests__/finished-product-factory-intake.service.test.ts b/furniture-3d-system/src/server/services/__tests__/finished-product-factory-intake.service.test.ts new file mode 100644 index 00000000..82f4bdc3 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/finished-product-factory-intake.service.test.ts @@ -0,0 +1,158 @@ +import { + FinishedProductFactoryIntakeService, + FACTORY_INTAKE_LAUNCH_CUTOFF, + linkedProductIdFromFlowUrl, + type FinishedProductFactoryIntakeDeps, +} from '@/server/services/finished-product-factory-intake.service' + +type CreatedRow = Record + +const FLOW_URL = 'https://cp.jiansun.vip/product-development/non-integrated/prod-1/detail' + +function makeDeps() { + const created: CreatedRow[] = [] + const deps: FinishedProductFactoryIntakeDeps = { + loadProduct: async () => ({ + id: 'prod-1', + brand: '大货', + purchaser: '采购小王', + payload: { + uploadFiles: [ + { category: '设计卖点', mimeType: 'image/png', dataUrl: 'https://img.example/design.png' }, + ], + factoryRows: [ + { + skuId: 'sku-1', + confirmed: true, + model: 'GBSFY1234', + barcode: 'GBSFY1234N1410V01', + specName: '款式五 100/14/2.5 碳纤维黑色 翻板', + supplierName: '三环', + }, + { + skuId: 'sku-2', + confirmed: false, + model: 'GBSFY1235', + barcode: 'GBSFY1235N1410V01', + specName: '款式六', + supplierName: '三环', + }, + { + skuId: 'sku-3', + confirmed: true, + model: '', + barcode: '', + specName: '缺货号条码', + supplierName: '三环', + }, + { + skuId: 'sku-4', + confirmed: true, + model: 'GBPM1234', + barcode: 'GBPM1234N205V01', + specName: '款式七 120/60 白色', + supplierName: '瑞敏', + }, + ], + }, + }), + findExistingAutoRow: async (itemNumber, barcode) => { + // 模拟真实持久化:本次测试会话内已创建的行也算已存在 + if (created.some((c) => c.itemNumber === itemNumber && (c.barcode ?? null) === barcode)) { + return { id: 'created-in-session' } + } + if (itemNumber === 'GBSFY1234') return { id: 'existing-1' } + return null + }, + createAutoRow: async (data) => { + created.push({ ...data }) + return { id: `created-${created.length}` } + }, + listCompletedSamplesSince: async () => [ + { openingCode: 'XP2026062500000226', productDevelopmentFlowUrl: FLOW_URL }, + { openingCode: 'XP2026070100000301', productDevelopmentFlowUrl: null }, + ], + } + return { deps, created } +} + +describe('FinishedProductFactoryIntakeService(打样完结带入成品进度)', () => { + it('打样完结时:只带入定厂已确认的 SKU,幂等跳过已存在行,映射字段正确', async () => { + const { deps, created } = makeDeps() + const service = new FinishedProductFactoryIntakeService(deps) + + const result = await service.syncCompletedSampleToFinishedProgress( + { openingCode: 'XP2026062500000226', productDevelopmentFlowUrl: FLOW_URL }, + { id: 'user-1', name: '徐霞客' }, + ) + + // sku-1 已存在跳过;sku-2 未确认跳过;sku-3 缺货号条码跳过;sku-4 创建 + expect(result).toEqual({ created: 1, skipped: 1 }) + expect(created).toHaveLength(1) + expect(created[0]).toMatchObject({ + itemNumber: 'GBPM1234', + barcode: 'GBPM1234N205V01', + productName: '款式七 120/60 白色', + brand: '大货', + supplier: '瑞敏', + imageUrl: 'https://img.example/design.png', + followUpOwner: '采购小王', + createdById: 'user-1', + createdByName: '徐霞客', + }) + expect(String(created[0].remarks)).toContain('定厂完成自动生成') + expect(String(created[0].remarks)).toContain('XP2026062500000226') + }) + + it('由壹点链接解析不出产品 ID 时返回零值', async () => { + const { deps, created } = makeDeps() + const service = new FinishedProductFactoryIntakeService(deps) + await expect(service.syncCompletedSampleToFinishedProgress({ + openingCode: 'XP1', + productDevelopmentFlowUrl: 'https://cp.jiansun.vip/other/path', + })).resolves.toEqual({ created: 0, skipped: 0 }) + expect(created).toHaveLength(0) + }) + + it('产品不存在时返回零值', async () => { + const deps: FinishedProductFactoryIntakeDeps = { + loadProduct: async () => null, + findExistingAutoRow: async () => null, + createAutoRow: async () => ({ id: 'x' }), + } + const service = new FinishedProductFactoryIntakeService(deps) + await expect(service.syncCompletedSampleToFinishedProgress({ + openingCode: 'XP1', + productDevelopmentFlowUrl: FLOW_URL, + })).resolves.toEqual({ created: 0, skipped: 0 }) + }) + + it('linkedProductIdFromFlowUrl 解析 /non-integrated/', () => { + expect(linkedProductIdFromFlowUrl(FLOW_URL)).toBe('prod-1') + expect(linkedProductIdFromFlowUrl('not-a-url')).toBe('') + expect(linkedProductIdFromFlowUrl(null)).toBe('') + }) + + it('每日补抓:扫描上线后完结的打样单并重试,无链接的跳过、同天只跑一次', async () => { + const { deps, created } = makeDeps() + let now = new Date('2026-09-10T01:30:00Z') + const service = new FinishedProductFactoryIntakeService(deps, () => now) + + const first = await service.sweepOncePerDay() + // 两条完结单:一条有链接(创建 1 条,1 条已存在跳过),一条无链接(抓不到,明天再试) + expect(first).toEqual({ scanned: 2, created: 1, skipped: 1 }) + expect(created).toHaveLength(1) + + // 同一天再刷列表:守卫生效,不再扫 + now = new Date('2026-09-10T08:30:00Z') + await expect(service.sweepOncePerDay()).resolves.toEqual({ scanned: 0, created: 0, skipped: 0 }) + expect(created).toHaveLength(1) + + // 第二天:再次扫描,幂等跳过已抓到的 + now = new Date('2026-09-11T01:30:00Z') + const third = await service.sweepOncePerDay() + expect(third.scanned).toBe(2) + expect(created).toHaveLength(1) + expect(FACTORY_INTAKE_LAUNCH_CUTOFF).toBe('2026-09-04') + }) +}) diff --git a/furniture-3d-system/src/server/services/finished-product-factory-intake.service.ts b/furniture-3d-system/src/server/services/finished-product-factory-intake.service.ts new file mode 100644 index 00000000..be46a8a5 --- /dev/null +++ b/furniture-3d-system/src/server/services/finished-product-factory-intake.service.ts @@ -0,0 +1,207 @@ +import { prisma } from '@/lib/prisma' + +const AUTO_CURRENT_NODE = '合同待签' + +/** 功能上线日:打样完结时间早于该日的属于历史数据,每日补抓不扫描。 */ +export const FACTORY_INTAKE_LAUNCH_CUTOFF = '2026-09-04' + +export type FinishedProductFactoryIntakeActor = { + id?: string | null + name?: string | null +} + +export type FinishedProductFactoryIntakeResult = { + created: number + skipped: number +} + +export type FinishedProductFactoryAutoRowInput = { + itemNumber: string + barcode: string | null + productName: string + brand: string + supplier: string + imageUrl: string | null + followUpOwner: string + remarks: string + createdById: string | null + createdByName: string +} + +export type FinishedProductFactoryIntakeDeps = { + loadProduct(productId: string): Promise<{ + id: string + brand: string + purchaser: string + payload: unknown + } | null> + findExistingAutoRow(itemNumber: string, barcode: string | null): Promise<{ id: string } | null> + createAutoRow(data: FinishedProductFactoryAutoRowInput): Promise<{ id: string }> + /** 上线日后完结的打样单(开款编号+由壹点链接),每日补抓扫描用 */ + listCompletedSamplesSince(cutoffDate: string): Promise> +} + +function readText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +/** 由壹点链接 → 产品开发记录 ID(/non-integrated/) */ +export function linkedProductIdFromFlowUrl(flowUrl: string | null | undefined) { + try { + const segments = new URL(flowUrl ?? '').pathname.split('/').filter(Boolean) + const index = segments.lastIndexOf('non-integrated') + return index >= 0 ? decodeURIComponent(segments[index + 1] || '') : '' + } catch { + return '' + } +} + +// 与打样进度 OA 同步同口径:优先「设计卖点」图片,其次款式图 +function extractProductImage(payload: unknown) { + if (!isRecord(payload)) return null + const files = Array.isArray(payload.uploadFiles) ? payload.uploadFiles : [] + const designFile = files.find((value) => { + if (!isRecord(value)) return false + return value.category === '设计卖点' + && String(value.mimeType || '').startsWith('image/') + && typeof value.dataUrl === 'string' + && Boolean(value.dataUrl.trim()) + }) as { dataUrl?: string } | undefined + if (designFile?.dataUrl?.trim()) return designFile.dataUrl.trim() + const styleImages = Array.isArray(payload.styleImages) ? payload.styleImages : [] + const styleImage = styleImages.find((value) => isRecord(value) && typeof value.url === 'string' && Boolean(value.url.trim())) as { url?: string } | undefined + return styleImage?.url?.trim() || null +} + +export class FinishedProductFactoryIntakeService { + private lastSweepDate: string | null = null + + constructor( + private readonly deps: FinishedProductFactoryIntakeDeps = { + loadProduct: async (productId) => prisma.intakeNonIntegratedProduct.findFirst({ + where: { id: productId, deletedAt: null }, + select: { id: true, brand: true, purchaser: true, payload: true }, + }), + findExistingAutoRow: async (itemNumber, barcode) => prisma.finishedProductProgressItem.findFirst({ + where: { contractNumber: '', itemNumber, barcode }, + select: { id: true }, + }), + createAutoRow: async (data) => prisma.finishedProductProgressItem.create({ + data: { + contractDateNumber: '', + contractNumber: '', + orderSequenceNo: '', + orderPlacedDate: null, + followUpOwner: data.followUpOwner, + imageUrl: data.imageUrl, + itemNumber: data.itemNumber, + barcode: data.barcode, + productName: data.productName, + supplier: data.supplier, + brand: data.brand, + orderedQuantity: 0, + inTransitQuantity: 0, + firstDueDate: null, + orderDueDate: null, + realtimeDate: null, + deliveryQuantity: 0, + currentNode: AUTO_CURRENT_NODE, + remarks: data.remarks, + createdById: data.createdById, + createdByName: data.createdByName, + updatedById: data.createdById, + updatedByName: data.createdByName, + }, + select: { id: true }, + }), + listCompletedSamplesSince: async (cutoffDate) => prisma.sampleProgressItem.findMany({ + where: { status: 'COMPLETED', actualSampleCompletedAt: { gte: new Date(`${cutoffDate}T00:00:00Z`) } }, + select: { openingCode: true, productDevelopmentFlowUrl: true }, + orderBy: { actualSampleCompletedAt: 'asc' }, + }), + }, + private readonly now: () => Date = () => new Date(), + ) {} + + /** 打样完结(实物审样完成,打样状态变为已完结)时调用:按开款编号+由壹点链接 + * 关联产品开发记录,把已定厂确认的 SKU 幂等带入成品进度明细(合同待签)。 + * 调用方负责只在「未完结 → 已完结」转换时触发,历史已完结产品不会被翻出。 */ + async syncCompletedSampleToFinishedProgress( + input: { openingCode: string; productDevelopmentFlowUrl: string | null }, + actor: FinishedProductFactoryIntakeActor = {}, + ): Promise { + const productId = linkedProductIdFromFlowUrl(input.productDevelopmentFlowUrl) + if (!productId) return { created: 0, skipped: 0 } + + const product = await this.deps.loadProduct(productId) + if (!product) return { created: 0, skipped: 0 } + + const payload = product.payload + const factoryRows = isRecord(payload) && Array.isArray(payload.factoryRows) ? payload.factoryRows : [] + if (factoryRows.length === 0) return { created: 0, skipped: 0 } + + const image = extractProductImage(payload) + const today = this.now().toISOString().slice(0, 10) + const actorId = actor.id ?? null + const actorName = actor.name || '系统' + + let created = 0 + let skipped = 0 + for (const row of factoryRows) { + if (!isRecord(row) || row.confirmed !== true) continue + const itemNumber = readText(row.model) + const barcode = readText(row.barcode) + if (!itemNumber && !barcode) continue + const existing = await this.deps.findExistingAutoRow(itemNumber, barcode || null) + if (existing) { + skipped += 1 + continue + } + await this.deps.createAutoRow({ + itemNumber, + barcode: barcode || null, + productName: readText(row.specName), + brand: product.brand || '', + supplier: readText(row.supplierName), + imageUrl: image, + followUpOwner: product.purchaser || '', + remarks: `定厂完成自动生成 ${today}(打样单 ${input.openingCode})`, + createdById: actorId, + createdByName: actorName, + }) + created += 1 + } + return { created, skipped } + } + + /** 每日补抓:扫描上线后完结的全部打样单并重试带入(幂等)。当时未定厂的会在 + * 定厂确认后的某天补抓中被抓到,直到抓到为止。 */ + async runDailyCatchUpSweep(actor: FinishedProductFactoryIntakeActor = {}): Promise { + const samples = await this.deps.listCompletedSamplesSince(FACTORY_INTAKE_LAUNCH_CUTOFF) + let created = 0 + let skipped = 0 + let scanned = 0 + for (const sample of samples) { + if (!sample.openingCode) continue + scanned += 1 + const result = await this.syncCompletedSampleToFinishedProgress(sample, actor) + created += result.created + skipped += result.skipped + } + this.lastSweepDate = this.now().toISOString().slice(0, 10) + return { scanned, created, skipped } + } + + /** 每天最多执行一次补抓,其余调用直接跳过(进程内守卫)。 */ + sweepOncePerDay(actor: FinishedProductFactoryIntakeActor = {}): Promise { + const today = this.now().toISOString().slice(0, 10) + if (this.lastSweepDate === today) return Promise.resolve({ scanned: 0, created: 0, skipped: 0 }) + return this.runDailyCatchUpSweep(actor) + } +} + +export const finishedProductFactoryIntakeService = new FinishedProductFactoryIntakeService() diff --git a/furniture-3d-system/src/server/services/sample-progress.service.ts b/furniture-3d-system/src/server/services/sample-progress.service.ts index d119fd74..232825e4 100644 --- a/furniture-3d-system/src/server/services/sample-progress.service.ts +++ b/furniture-3d-system/src/server/services/sample-progress.service.ts @@ -17,6 +17,7 @@ import { sampleProgressSupplierSnapshot, } from '@/server/services/sample-progress-change-log' import { serializeSampleProgress } from '@/server/services/sample-progress-serialization' +import { finishedProductFactoryIntakeService } from '@/server/services/finished-product-factory-intake.service' import { buildSampleProgressParentSummary, resolveSupplierWrites, @@ -242,7 +243,8 @@ async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) { const endedAt = endedAtFromOa ?? ( current?.autoGenerated ? null : current?.actualSampleCompletedAt ?? null ) - return sampleProgressRepository.updateOaFields({ + const previousStatus = current?.status ?? null + const updatePromise = sampleProgressRepository.updateOaFields({ openingCode, brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '', designer: structureDesignerByProductId.get(productId) || item.formSummary.structureDesigner || '', @@ -256,6 +258,24 @@ async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) { imageUrl: designImageByProductId.get(linkedProductId(workflowLink)) || item.styleImages[0]?.url || null, productDevelopmentFlowUrl: workflowLink || null, }) + // 打样状态从未完结变为已完结(实物审样完成)时,把已定厂 SKU 自动带入成品进度明细 + if (nextStatus === 'COMPLETED' && previousStatus !== 'COMPLETED') { + return updatePromise.then(async () => { + try { + const syncResult = await finishedProductFactoryIntakeService.syncCompletedSampleToFinishedProgress({ + openingCode, + productDevelopmentFlowUrl: workflowLink || null, + }) + if (syncResult.created > 0) { + console.log('[sample-progress] 打样完结自动带入成品进度明细:', { openingCode, ...syncResult }) + } + } catch (error) { + console.error(`[sample-progress] 打样完结自动带入成品进度失败 (${openingCode}):`, error) + } + return { count: 1 } + }) + } + return updatePromise })) } @@ -327,6 +347,10 @@ export const sampleProgressService = { async listStored() { return (await sampleProgressRepository.list()).map(serializeSampleProgress) }, async list() { await this.syncFromOaDashboard().catch(() => undefined) + // 每日一次:上线后完结但当时未定厂的打样单,补抓已定厂 SKU 直到抓到 + await finishedProductFactoryIntakeService.sweepOncePerDay().catch((error) => { + console.error('[sample-progress] 定厂 SKU 每日补抓失败:', error) + }) return this.listStored() }, getOaPrefill: readOaPrefill, @@ -393,7 +417,7 @@ export const sampleProgressService = { const changeReason = changedDueSuppliers.map((supplier, index) => `${supplier.supplier || `供应商 ${index + 1}`}:${supplier.deliveryDateChangeReason?.trim()}`, ).join('\n') || deliveryDateChangeReason?.trim() || null - return serializeSampleProgress(await sampleProgressRepository.updateWithChangeLog({ + const record = await sampleProgressRepository.updateWithChangeLog({ id, data: { ...merged, openingCode: current.openingCode, autoGenerated: false, updatedBy: actor.id }, suppliers: supplierWrites, @@ -403,7 +427,22 @@ export const sampleProgressService = { changeReason, changedById: actor.id, changedByName: actor.name || '未知用户', - })) + }) + // 人工把打样状态改为已完结(实物审样完成)时,把已定厂 SKU 自动带入成品进度明细 + if (current.status !== 'COMPLETED' && record.status === 'COMPLETED') { + try { + const syncResult = await finishedProductFactoryIntakeService.syncCompletedSampleToFinishedProgress({ + openingCode: current.openingCode, + productDevelopmentFlowUrl: current.productDevelopmentFlowUrl ?? null, + }, { id: actor.id, name: actor.name }) + if (syncResult.created > 0) { + console.log('[sample-progress] 打样完结(人工)自动带入成品进度明细:', { openingCode: current.openingCode, ...syncResult }) + } + } catch (error) { + console.error(`[sample-progress] 打样完结(人工)自动带入成品进度失败 (${current.openingCode}):`, error) + } + } + return serializeSampleProgress(record) }, async listChangeLogs(id: string) { if (!(await sampleProgressRepository.findById(id))) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在') From f8ba4f1e2563bbd7068cb7d549ed6eb1c0488966 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 7 Sep 2026 11:47:40 +0800 Subject: [PATCH 13/16] feat(intake): open sample measurement and pricing template access with spec name column measurement and pricing nodes now default downloads for pricing/purchase/merchandiser subjects so non-admin owners see the export/import template buttons; both workbook exports add a spec name reference column after the barcode (imports stay barcode-keyed and ignore it); the pricing node also grants merchandiser download parity with purchase owners. --- .../product-workflow/productWorkflowRowHelpers.ts | 1 + .../sample-workflow/sampleWorkflowHelpers.ts | 1 + .../src/lib/intake-node-permission-settings.ts | 10 ++++++++++ ...on-integrated-product-pricing-workbook.test.ts | 7 +++++-- .../non-integrated-product-pricing-workbook.ts | 4 +++- ...integrated-sample-measurement-workbook.test.ts | 15 +++++++++------ .../non-integrated-sample-measurement-workbook.ts | 4 +++- 7 files changed, 32 insertions(+), 10 deletions(-) diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/productWorkflowRowHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/productWorkflowRowHelpers.ts index 0b229c13..de1933a5 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/productWorkflowRowHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/productWorkflowRowHelpers.ts @@ -176,6 +176,7 @@ export function buildProductPricingWorkbookRows( return rows.map((row) => ({ image: row.image.trim(), barcode: row.barcode.trim(), + specName: row.specName.trim(), costPrice: row.costPrice.trim(), })); } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts index fa7093ad..a5f892b1 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts @@ -104,6 +104,7 @@ export function buildSampleMeasurementWorkbookRows( return rows.map((row) => ({ image: row.image.trim(), barcode: row.barcode.trim(), + specName: row.specName.trim(), lengthCm: row.lengthCm.trim(), widthCm: row.widthCm.trim(), heightCm: row.heightCm.trim(), diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.ts index 08ccce79..3ac3a2b9 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.ts @@ -623,6 +623,11 @@ export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[ pricing: "edit", externalIntake: "edit", }, + downloads: { + merchandiser: true, + pricing: true, + purchase: true, + }, }, { key: "finishedSampleShipment", @@ -645,6 +650,11 @@ export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[ pricing: "edit", externalIntake: "edit", }, + downloads: { + merchandiser: true, + pricing: true, + purchase: true, + }, }, { key: "limit", diff --git a/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.test.ts b/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.test.ts index 8bf73f13..76357136 100644 --- a/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.test.ts @@ -27,6 +27,7 @@ describe('non-integrated-product-pricing-workbook', () => { { image: PRODUCT_PRICING_TEST_IMAGE, barcode: 'GBGJ-XJ009601N1280V01', + specName: '款式一 45/45/50 胡桃色', costPrice: '88', }, ]) @@ -40,9 +41,11 @@ describe('non-integrated-product-pricing-workbook', () => { expect(worksheet?.name).toBe('产品核价') expect(worksheet?.getCell(1, 1).value).toBe('产品图片') expect(worksheet?.getCell(1, 2).value).toBe('条码') - expect(worksheet?.getCell(1, 3).value).toBe('成本价') + expect(worksheet?.getCell(1, 3).value).toBe('条码规格名称') + expect(worksheet?.getCell(1, 4).value).toBe('成本价') expect(worksheet?.getCell(2, 2).value).toBe('GBGJ-XJ009601N1280V01') - expect(worksheet?.getCell(2, 3).value).toBe(88) + expect(worksheet?.getCell(2, 3).value).toBe('款式一 45/45/50 胡桃色') + expect(worksheet?.getCell(2, 4).value).toBe(88) const media = (workbook as import('exceljs').Workbook & { model?: { media?: unknown[] } diff --git a/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.ts b/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.ts index b2c402f7..cd3a45b5 100644 --- a/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.ts +++ b/furniture-3d-system/src/lib/non-integrated-product-pricing-workbook.ts @@ -1,6 +1,7 @@ export type NonIntegratedProductPricingWorkbookRow = { image: string barcode: string + specName: string costPrice: string } @@ -20,7 +21,7 @@ type ProductPricingWorkbookImageExtension = 'jpeg' | 'png' | 'gif' type ProductPricingWorkbookExportColumn = | { - key: 'barcode' + key: 'barcode' | 'specName' label: string width: number kind: 'text' @@ -42,6 +43,7 @@ const NON_INTEGRATED_PRODUCT_PRICING_EXPORT_COLUMNS: ProductPricingWorkbookExpor [ { key: 'image', label: '产品图片', width: 14, kind: 'image' }, { key: 'barcode', label: '条码', width: 24, kind: 'text' }, + { key: 'specName', label: '条码规格名称', width: 24, kind: 'text' }, { key: 'costPrice', label: '成本价', width: 12, kind: 'number' }, ] diff --git a/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.test.ts b/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.test.ts index dd4a84d9..67ddf5cd 100644 --- a/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.test.ts @@ -27,6 +27,7 @@ describe('non-integrated-sample-measurement-workbook', () => { { image: SAMPLE_MEASUREMENT_TEST_IMAGE, barcode: 'XJ010101N3576V01', + specName: '款式一 1/1/1 黑色', lengthCm: '30', widthCm: '40', heightCm: '60', @@ -43,13 +44,15 @@ describe('non-integrated-sample-measurement-workbook', () => { expect(worksheet?.name).toBe('样包测量') expect(worksheet?.getCell(1, 1).value).toBe('产品图片') expect(worksheet?.getCell(1, 2).value).toBe('条码') - expect(worksheet?.getCell(1, 3).value).toBe('长(CM)') - expect(worksheet?.getCell(1, 4).value).toBe('宽(CM)') - expect(worksheet?.getCell(1, 5).value).toBe('高(CM)') - expect(worksheet?.getCell(1, 6).value).toBe('重量(KG)') + expect(worksheet?.getCell(1, 3).value).toBe('条码规格名称') + expect(worksheet?.getCell(1, 4).value).toBe('长(CM)') + expect(worksheet?.getCell(1, 5).value).toBe('宽(CM)') + expect(worksheet?.getCell(1, 6).value).toBe('高(CM)') + expect(worksheet?.getCell(1, 7).value).toBe('重量(KG)') expect(worksheet?.getCell(2, 2).value).toBe('XJ010101N3576V01') - expect(worksheet?.getCell(2, 3).value).toBe(30) - expect(worksheet?.getCell(2, 6).value ?? '').toBe('') + expect(worksheet?.getCell(2, 3).value).toBe('款式一 1/1/1 黑色') + expect(worksheet?.getCell(2, 4).value).toBe(30) + expect(worksheet?.getCell(2, 7).value ?? '').toBe('') const media = (workbook as import('exceljs').Workbook & { model?: { media?: unknown[] } diff --git a/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.ts b/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.ts index 92a5aacf..43cc4e83 100644 --- a/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.ts +++ b/furniture-3d-system/src/lib/non-integrated-sample-measurement-workbook.ts @@ -1,6 +1,7 @@ export type NonIntegratedSampleMeasurementWorkbookRow = { image: string barcode: string + specName: string lengthCm: string widthCm: string heightCm: string @@ -26,7 +27,7 @@ type SampleMeasurementWorkbookImageExtension = 'jpeg' | 'png' | 'gif' type SampleMeasurementWorkbookExportColumn = | { - key: 'barcode' + key: 'barcode' | 'specName' label: string width: number kind: 'text' @@ -48,6 +49,7 @@ const NON_INTEGRATED_SAMPLE_MEASUREMENT_EXPORT_COLUMNS: SampleMeasurementWorkboo [ { key: 'image', label: '产品图片', width: 14, kind: 'image' }, { key: 'barcode', label: '条码', width: 24, kind: 'text' }, + { key: 'specName', label: '条码规格名称', width: 24, kind: 'text' }, { key: 'lengthCm', label: '长(CM)', width: 12, kind: 'number' }, { key: 'widthCm', label: '宽(CM)', width: 12, kind: 'number' }, { key: 'heightCm', label: '高(CM)', width: 12, kind: 'number' }, From d4d26c43bf593991ccf1944cb80f953ec33a0f7c Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 7 Sep 2026 16:38:54 +0800 Subject: [PATCH 14/16] =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E6=96=B0?= =?UTF-8?q?=E5=93=81=E6=8E=A8=E8=8D=90=EF=BC=9A=E6=96=B0=E5=A2=9E=E9=87=87?= =?UTF-8?q?=E8=B4=AD=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF=E4=B8=8EPK?= =?UTF-8?q?=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 看板双视图(明细数据/采购看板,仅采购与管理可见看板);按月聚合采购推荐SKU/涉及工厂/被选款/被选率;路径行明细支持工厂勾选后跳转明细数据;被选款数量卡片跳转已选明细;PK榜爬山形态铺满全屏,管理员可配置主题/总金额/层级门槛与金额/规则说明(存 SystemConfig,无迁移)。 --- .../dashboard/route.ts | 17 ++ .../pk-board/config/route.ts | 18 ++ .../pk-board/route.ts | 17 ++ .../SupplierRecommendedProductsClient.tsx | 68 +++-- .../SupplierRecommendedProductDashboard.tsx | 240 ++++++++++++++++++ .../SupplierRecommendedProductPkBoard.tsx | 81 ++++++ ...pplierRecommendedProductPkConfigDialog.tsx | 94 +++++++ .../supplier-recommended-products/page.tsx | 3 +- .../supplier-recommended-products.css | 38 ++- .../src/lib/supplier-recommended-products.ts | 118 +++++++++ .../lib/supply-chain-permission-settings.ts | 2 +- ...plier-recommended-products.service.test.ts | 106 ++++++++ .../supplier-recommended-products.service.ts | 159 +++++++++++- 13 files changed, 937 insertions(+), 24 deletions(-) create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/dashboard/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/config/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/route.ts create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductDashboard.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkBoard.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkConfigDialog.tsx diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/dashboard/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/dashboard/route.ts new file mode 100644 index 00000000..fab49968 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/dashboard/route.ts @@ -0,0 +1,17 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +const dashboardSchema = z.object({ + month: z.string().trim().regex(/^\d{4}-\d{2}$/, '月份格式应为 YYYY-MM').optional(), +}) + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const parsed = dashboardSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '看板月份参数不正确', 400, ctx.requestId) + return jsonSuccess(await supplierRecommendedProductsService.getDashboard(parsed.data.month, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/config/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/config/route.ts new file mode 100644 index 00000000..e45672bf --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/config/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + return jsonSuccess(await supplierRecommendedProductsService.getPkConfig(), 200, ctx.requestId) + }, { requireAuth: true }) +} + +export async function PUT(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'PK榜配置格式不正确', 400, ctx.requestId) + return jsonSuccess(await supplierRecommendedProductsService.updatePkConfig(body, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/route.ts new file mode 100644 index 00000000..04ecc1e2 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/pk-board/route.ts @@ -0,0 +1,17 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +const pkBoardSchema = z.object({ + month: z.string().trim().regex(/^\d{4}-\d{2}$/, '月份格式应为 YYYY-MM').optional(), +}) + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const parsed = pkBoardSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'PK榜月份参数不正确', 400, ctx.requestId) + return jsonSuccess(await supplierRecommendedProductsService.getPkBoard(parsed.data.month, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx index 6cdf3699..c9fd87ea 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx @@ -5,6 +5,7 @@ import { BadgeCheck, FileDown, Grid2X2, List, Plus, RotateCcw, Search, Trash2, U import { useCallback, useEffect, useRef, useState } from 'react' import { EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS, type SupplierRecommendedProductChangeLogEntry, type SupplierRecommendedProductFilters, type SupplierRecommendedProductOperator, type SupplierRecommendedProductPage, type SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products' import { SupplierRecommendedProductColumnSettings } from './components/SupplierRecommendedProductColumnSettings' +import { SupplierRecommendedProductDashboard } from './components/SupplierRecommendedProductDashboard' import { SupplierRecommendedProductCardGrid } from './components/SupplierRecommendedProductCardGrid' import { SupplierRecommendedProductDeleteDialog } from './components/SupplierRecommendedProductDeleteDialog' import { SupplierRecommendedProductDetailDialog } from './components/SupplierRecommendedProductDetailDialog' @@ -36,9 +37,10 @@ function openDatePicker(event: React.MouseEvent) { } } -export function SupplierRecommendedProductsClient({ initialData, canEdit, canDownload, canSelect, currentUser }: { initialData: SupplierRecommendedProductPage; canEdit: boolean; canDownload: boolean; canSelect: boolean; currentUser: { id: string; name: string } }) { +export function SupplierRecommendedProductsClient({ initialData, canEdit, canDownload, canSelect, canViewDashboard, currentUser }: { initialData: SupplierRecommendedProductPage; canEdit: boolean; canDownload: boolean; canSelect: boolean; canViewDashboard: boolean; currentUser: { id: string; name: string } }) { const [data, setData] = useState(initialData) const [filters, setFilters] = useState({ ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS }) + const [pageView, setPageView] = useState<'list' | 'dashboard'>(canViewDashboard ? 'dashboard' : 'list') const [editor, setEditor] = useState(null) const [logs, setLogs] = useState(null) const [preview, setPreview] = useState(null) @@ -117,6 +119,22 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow void load({ ...filters, selection }, 1, data.pageInfo.pageSize) } + // 看板点击「被选款数量」:跳到明细数据并筛出已选 + const openSelectedInList = () => { + setPageView('list') + if (filters.selection !== 'selected') { + void load({ ...filters, selection: 'selected' }, 1, data.pageInfo.pageSize) + } + } + + // 看板点击「涉及工厂」:跳到明细数据并按当月涉及的工厂 + 月份范围筛选 + const openFactoriesInList = (factories: string[], month: string) => { + setPageView('list') + const [year, monthNumber] = month.split('-').map(Number) + const lastDay = new Date(Date.UTC(year, monthNumber, 0)).getUTCDate() + void load({ ...filters, supplier: factories, selection: 'all', dateFrom: `${month}-01`, dateTo: `${month}-${String(lastDay).padStart(2, '0')}` }, 1, data.pageInfo.pageSize) + } + const submitSelection = async (action: 'select' | 'return', selectedOperator?: SupplierRecommendedProductOperator) => { const ids = [...selectedIds] if (ids.length === 0 || selectionBusy) return @@ -150,6 +168,26 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow void submitSelection(action) } + const downloadExportFile = async (query: URLSearchParams) => { + const response = await fetch(`/api/supply-chain/supplier-recommended-products/export?${query}`, { cache: 'no-store' }) + if (!response.ok) { + const payload = await response.json().catch(() => null) + throw new Error(payload?.error?.message || '导出失败') + } + const blob = await response.blob() + const disposition = response.headers.get('Content-Disposition') || '' + const match = /filename\*=UTF-8''([^;]+)/.exec(disposition) + const filename = match ? decodeURIComponent(match[1]) : '供应商新品推荐导出.xlsx' + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) + } + const exportSelected = async () => { if (exportingSelected) return setExportingSelected(true) @@ -160,23 +198,7 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow if (value.length) params.set(key, value.join(',')) } else if (value) params.set(key, String(value)) }) - const response = await fetch(`/api/supply-chain/supplier-recommended-products/export?${params}`, { cache: 'no-store' }) - if (!response.ok) { - const payload = await response.json().catch(() => null) - throw new Error(payload?.error?.message || '导出失败') - } - const blob = await response.blob() - const disposition = response.headers.get('Content-Disposition') || '' - const match = /filename\*=UTF-8''([^;]+)/.exec(disposition) - const filename = match ? decodeURIComponent(match[1]) : '供应商新品推荐导出.xlsx' - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = filename - document.body.appendChild(anchor) - anchor.click() - anchor.remove() - URL.revokeObjectURL(url) + await downloadExportFile(params) setFeedback({ tone: 'success', message: '导出成功,文件已开始下载' }) } catch (error) { setFeedback({ tone: 'error', message: error instanceof Error ? error.message : '导出失败' }) @@ -216,8 +238,12 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow const commitPage = () => { const parsed = Number(pageInput); if (!Number.isFinite(parsed)) return setPageInput(String(data.pageInfo.page)); const page = Math.min(Math.max(1, Math.trunc(parsed)), data.pageInfo.totalPages); setPageInput(String(page)); if (page !== data.pageInfo.page) void load(filters, page, data.pageInfo.pageSize) } return
-

供应商新品推荐

维护供应商自荐产品资料、寄样和发货信息

+ {canViewDashboard &&
+ + +
}
+ {pageView === 'list' && <>
{(['all', 'unselected', 'selected', 'discarded'] as const).map((selection) => )}
@@ -231,9 +257,12 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow } {canSelect && } {canDownload && filters.selection === 'selected' && } + }
+ {pageView === 'dashboard' && } + {pageView === 'list' && <> { event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}> setFilters({ ...filters, search: event.target.value })} placeholder="搜索供应商、采购、产品代码、材质或发货模式" /> setFilters({ ...filters, category })} /> @@ -253,6 +282,7 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow {viewMode === 'table' ? 0 && selectableRows.every((row) => selectedIds.has(row.id))} onToggleSelection={toggleSelection} onToggleAll={toggleAllSelection} onOpenDetail={setDetail} onEdit={setEditor} onDelete={remove} onHistory={(row) => void openHistory(row)} onPreview={setPreview} /> : setEditor(row)} onDelete={remove} onHistory={(row) => void openHistory(row)} />} void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} />
+ } {editor && setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />} {detail && setDetail(null)} onEdit={(row) => { setDetail(null); setEditor(row) }} onDelete={(row) => { setDetail(null); remove(row) }} onHistory={(row) => { setDetail(null); void openHistory(row) }} onPreview={(row) => setPreview(row)} />} {logs && setLogs(null)} />} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductDashboard.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductDashboard.tsx new file mode 100644 index 00000000..43c54f47 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductDashboard.tsx @@ -0,0 +1,240 @@ +'use client' + +import { ChevronDown, ChevronRight, LoaderCircle, Table2, Trophy } from 'lucide-react' +import { Fragment, useCallback, useEffect, useState } from 'react' +import type { SupplierRecommendedProductDashboard, SupplierRecommendedProductDashboardRow } from '@/lib/supplier-recommended-products' +import { SupplierRecommendedProductPkBoard } from './SupplierRecommendedProductPkBoard' + +type BoardView = 'table' | 'pk' + +const BOARD_VIEW_STORAGE_KEY = 'supply-chain:supplier-recommended-products:dashboard-view' + +type Props = { + onOpenSelected: () => void + onOpenFactories: (factories: string[], month: string) => void +} + +type PathRow = { + key: string + factory: string + category: string + material: string + skuCount: number + selectedCount: number +} + +function flattenPaths(row: SupplierRecommendedProductDashboardRow): PathRow[] { + const paths: PathRow[] = [] + for (const factory of row.factories) { + for (const category of factory.categories) { + const materialSku = category.materials.reduce((sum, material) => sum + material.skuCount, 0) + const materialSelected = category.materials.reduce((sum, material) => sum + material.selectedCount, 0) + if (category.skuCount > materialSku || category.selectedCount > materialSelected) { + paths.push({ + key: `${factory.factory}/${category.category}`, + factory: factory.factory, + category: category.category, + material: '', + skuCount: category.skuCount - materialSku, + selectedCount: category.selectedCount - materialSelected, + }) + } + for (const material of category.materials) { + paths.push({ + key: `${factory.factory}/${category.category}/${material.material}`, + factory: factory.factory, + category: category.category, + material: material.material, + skuCount: material.skuCount, + selectedCount: material.selectedCount, + }) + } + } + } + return paths +} + +function currentMonthValue(now = new Date()) { + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` +} + +export function SupplierRecommendedProductDashboard({ onOpenSelected, onOpenFactories }: Props) { + const [month, setMonth] = useState(currentMonthValue()) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [expandedPurchasers, setExpandedPurchasers] = useState>(() => new Set()) + const [selectMode, setSelectMode] = useState(false) + const [checkedPaths, setCheckedPaths] = useState>(() => new Set()) + const [boardView, setBoardView] = useState('table') + + // 浏览器本地偏好需在水合后读取,避免 SSR 阶段访问 localStorage + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + try { + const stored = window.localStorage.getItem(BOARD_VIEW_STORAGE_KEY) + if (stored === 'table' || stored === 'pk') setBoardView(stored) + } catch { + // 本地存储不可用时保持默认表格形态 + } + }, []) + /* eslint-enable react-hooks/set-state-in-effect */ + + const changeBoardView = (next: BoardView) => { + setBoardView(next) + try { + window.localStorage.setItem(BOARD_VIEW_STORAGE_KEY, next) + } catch { + // 本地存储不可用时仅当前会话生效 + } + } + + const load = useCallback(async (targetMonth: string) => { + setLoading(true) + setError('') + try { + const response = await fetch(`/api/supply-chain/supplier-recommended-products/dashboard?month=${encodeURIComponent(targetMonth)}`, { cache: 'no-store' }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '看板数据加载失败') + setData(payload.data as SupplierRecommendedProductDashboard) + } catch (requestError) { + setData(null) + setError(requestError instanceof Error ? requestError.message : '看板数据加载失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void load(month) + }, [month, load]) + + const togglePurchaser = (key: string) => setExpandedPurchasers((current) => { + const next = new Set(current) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + const allExpanded = data !== null && data.rows.length > 0 && expandedPurchasers.size >= data.rows.length + const expandAll = () => setExpandedPurchasers(new Set(data?.rows.map((row) => row.purchaser) ?? [])) + const collapseAll = () => setExpandedPurchasers(new Set()) + const totals = data?.totals + + const toggleSelectMode = () => setSelectMode((current) => { + const next = !current + if (next) { + setExpandedPurchasers(new Set(data?.rows.map((row) => row.purchaser) ?? [])) + } else { + setCheckedPaths(new Set()) + setExpandedPurchasers(new Set()) + } + return next + }) + const togglePath = (key: string) => setCheckedPaths((current) => { + const next = new Set(current) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + const checkedFactories = [...new Set([...checkedPaths].map((key) => (key.split('::')[1] ?? key).split('/')[0]))] + + return
+
+ + {totals &&
+
推荐SKU{totals.skuCount}
+ + +
被选率{totals.selectedRate}%
+
} +
+ {boardView === 'table' && data && data.rows.length > 0 && + + + } + {selectMode && + + + } +
+ + +
+
+
+ {loading &&
加载中
} + {!loading && error &&
{error}
} + {!loading && !error && boardView === 'pk' &&
} + {!loading && !error && boardView === 'table' && data &&
+ + + + + + + + + + + + + + + + {data.rows.length === 0 && } + {data.rows.map((row) => { + const expanded = expandedPurchasers.has(row.purchaser) + return + togglePurchaser(row.purchaser)} aria-expanded={expanded}> + + + + + + + + + + + {expanded && flattenPaths(row).map((path) => { + const rowKey = `${row.purchaser}::${path.key}` + return + + + + + + + + + + + })} + + })} + +
采购工厂类目材质SKU数量被选款数量业绩SABC评级
{month} 没有推荐数据
{expanded ? {row.purchaser}{row.factoryCount}——{row.skuCount}{row.selectedCount}{row.performance || '—'}{row.sabcRating || '—'}
{selectMode && togglePath(rowKey)} aria-label={`选择 ${path.factory} ${path.category}${path.material ? ` ${path.material}` : ''}`} />}{path.factory}{path.category}{path.material || '—'}{path.skuCount}{path.selectedCount}
+
} + {!loading && !error && boardView === 'table' && data &&

点击采购行展开该采购的 工厂 × 类目 × 材质 推荐明细;数据基于当月录入的推荐记录实时统计。

} +
+} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkBoard.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkBoard.tsx new file mode 100644 index 00000000..90a7ac60 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkBoard.tsx @@ -0,0 +1,81 @@ +'use client' + +import { LoaderCircle } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { type SupplierRecommendedProductPkBoard } from '@/lib/supplier-recommended-products' +import { SupplierRecommendedProductPkConfigDialog } from './SupplierRecommendedProductPkConfigDialog' + +function formatSavedAt(value: string | null) { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date) +} + +type Props = { month: string } + +export function SupplierRecommendedProductPkBoard({ month }: Props) { + const [board, setBoard] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [configOpen, setConfigOpen] = useState(false) + + const load = useCallback(async () => { + setLoading(true) + setError('') + try { + const response = await fetch(`/api/supply-chain/supplier-recommended-products/pk-board?month=${encodeURIComponent(month)}`, { cache: 'no-store' }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || 'PK榜数据加载失败') + setBoard(payload.data as SupplierRecommendedProductPkBoard) + } catch (requestError) { + setBoard(null) + setError(requestError instanceof Error ? requestError.message : 'PK榜数据加载失败') + } finally { + setLoading(false) + } + }, [month]) + + useEffect(() => { + void load() + }, [load]) + + const tiers = board?.config.tiers ?? [] + + return
+ {loading &&
加载中
} + {!loading && error &&
{error}
} + {!loading && !error && board &&
+
+
+
主题{board.config.theme}
+ {board.config.totalAmount &&
总金额¥{board.config.totalAmount}
} +
更新日期{formatSavedAt(board.config.savedAt) || '—'}
+
+ {board.canConfigure && } +
+
+ {tiers.map((tier, index) => { + const flags = board.entries.filter((entry) => entry.tierIndex === index) + return
+ {tier.amount &&
¥{tier.amount}
} +
{tier.label}
+
+ {flags.map((flag) =>
+ {flag.purchaser} + 被选{flag.selectedCount}款 +
)} +
+ +
+ })} +
+ {board.config.rules &&

其他规则:{board.config.rules}

} +
} + {configOpen && board && setConfigOpen(false)} + onSaved={() => { setConfigOpen(false); void load() }} + />} +
+} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkConfigDialog.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkConfigDialog.tsx new file mode 100644 index 00000000..974f19e1 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPkConfigDialog.tsx @@ -0,0 +1,94 @@ +'use client' + +import { LoaderCircle, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { type SupplierRecommendedProductPkConfig, type SupplierRecommendedProductPkTier } from '@/lib/supplier-recommended-products' + +type Props = { + config: SupplierRecommendedProductPkConfig + onClose: () => void + onSaved: () => void +} + +export function SupplierRecommendedProductPkConfigDialog({ config, onClose, onSaved }: Props) { + const [theme, setTheme] = useState(config.theme) + const [totalAmount, setTotalAmount] = useState(config.totalAmount) + const [rules, setRules] = useState(config.rules) + const [tiers, setTiers] = useState(() => config.tiers.map((tier) => ({ ...tier }))) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + + const updateTier = (index: number, patch: Partial) => { + setTiers((current) => current.map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier))) + } + + const submit = async () => { + setSaving(true) + setError('') + try { + const response = await fetch('/api/supply-chain/supplier-recommended-products/pk-board/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + theme, + totalAmount, + rules, + tiers: tiers.map((tier) => ({ label: tier.label, minCount: Number(tier.minCount) || 0, amount: tier.amount })), + }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || 'PK榜配置保存失败') + onSaved() + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : 'PK榜配置保存失败') + } finally { + setSaving(false) + } + } + + return
{ if (event.target === event.currentTarget && !saving) onClose() }}> +
+
+
+
PK榜设置
+ +
+ +
+
+
+ + +
+
+
层级(从低到高)被选款门槛金额
+ {tiers.map((tier, index) =>
+ updateTier(index, { label: event.target.value })} /> + updateTier(index, { minCount: Number(event.target.value.replace(/[^\d]/g, '')) || 0 })} /> + updateTier(index, { amount: event.target.value })} placeholder="如 200" /> + +
)} + +
+