From cf94cb3b883e714d230206044d2c57b95e447639 Mon Sep 17 00:00:00 2001 From: kae Date: Sun, 5 Jul 2026 07:33:09 +0900 Subject: [PATCH] feat: improve product yarn ratio workflow --- src/pages/Inventory/Products/index.test.tsx | 100 +++++- src/pages/Inventory/Products/index.tsx | 361 ++++++++++++++++++-- 2 files changed, 436 insertions(+), 25 deletions(-) diff --git a/src/pages/Inventory/Products/index.test.tsx b/src/pages/Inventory/Products/index.test.tsx index f6a80e5..c30774b 100644 --- a/src/pages/Inventory/Products/index.test.tsx +++ b/src/pages/Inventory/Products/index.test.tsx @@ -7,7 +7,7 @@ vi.mock('@/services/api'); import { smokeTest } from '../../../../test/smoke'; import Page from './index'; -import { createProduct, getProductSummary } from '@/services/api'; +import { createProduct, getProductSummary, listYarns } from '@/services/api'; const clickDialogSubmit = async (user: ReturnType, dialog: HTMLElement) => { const submitBtn = within(dialog).getByRole('button', { name: /确\s*定|确\s*认|提交|OK/i }); @@ -68,6 +68,104 @@ describe('Inventory / Products page', () => { }); }); + it('renders yarn ratio tables and blocks weight calculation until both sides are complete', async () => { + const user = userEvent.setup(); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await user.click(screen.getByRole('button', { name: /新增产品/ })); + await waitFor(() => screen.getByRole('dialog')); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getAllByText('纱线名称/纱线颜色')).toHaveLength(2); + expect(within(dialog).getAllByText('使用配比(合计 10)')).toHaveLength(2); + expect(within(dialog).getAllByRole('button', { name: '根据纱线配比计算' })).toHaveLength(1); + + await user.click(within(dialog).getByRole('button', { name: '根据纱线配比计算' })); + expect(await within(dialog).findByText('请先添加经线和纬线纱线,并确保两边配比合计均为 10')).toBeInTheDocument(); + }); + + it('submits selected yarn ratio rows as initial batch yarnRatio', async () => { + vi.mocked(listYarns).mockResolvedValue({ + code: 0, + msg: 'ok', + data: { + total: 1, + list: [ + { yarnId: 'yarn-1', yarnName: 'A纱', color: '红', weightGM: '20', supplierId: 'supplier-1' }, + ], + }, + } as any); + const user = userEvent.setup(); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await user.click(screen.getByRole('button', { name: /新增产品/ })); + await waitFor(() => screen.getByRole('dialog')); + + const dialog = screen.getByRole('dialog'); + await user.type(within(dialog).getByRole('textbox', { name: /产品名称/ }), '测试产品'); + await waitFor(() => expect(vi.mocked(listYarns)).toHaveBeenCalled()); + + await user.click(within(dialog).getByRole('combobox', { name: '添加经线纱线' })); + await user.click(await screen.findByText('A纱 / 红')); + await clickDialogSubmit(user, dialog); + + await waitFor(() => { + expect(vi.mocked(createProduct)).toHaveBeenCalled(); + }); + const payload = vi.mocked(createProduct).mock.calls.at(-1)?.[0] as any; + const yarnRatio = JSON.parse(payload.initialBatch.yarnRatio); + expect(yarnRatio.warp).toEqual([ + { yarn_id: 'yarn-1', yarn_name: 'A纱', color: '红', ratio: 10 }, + ]); + expect(yarnRatio.weft).toEqual([]); + }); + + it('rejects product creation when an existing yarn side does not add up to ten', async () => { + vi.mocked(listYarns).mockResolvedValue({ + code: 0, + msg: 'ok', + data: { + total: 1, + list: [ + { yarnId: 'yarn-1', yarnName: 'A纱', color: '红', weightGM: '20', supplierId: 'supplier-1' }, + ], + }, + } as any); + const user = userEvent.setup(); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await user.click(screen.getByRole('button', { name: /新增产品/ })); + await waitFor(() => screen.getByRole('dialog')); + + const dialog = screen.getByRole('dialog'); + await user.type(within(dialog).getByRole('textbox', { name: /产品名称/ }), '测试产品'); + await waitFor(() => expect(vi.mocked(listYarns)).toHaveBeenCalled()); + await user.click(within(dialog).getByRole('combobox', { name: '添加经线纱线' })); + await user.click(await screen.findByText('A纱 / 红')); + + const ratioInput = within(dialog).getByRole('spinbutton', { name: '经线A纱使用配比' }); + await user.click(ratioInput); + await user.keyboard('{Control>}a{/Control}9'); + await clickDialogSubmit(user, dialog); + + await waitFor(() => { + expect(screen.getAllByText('经线配比合计需为 10,当前为 9').length).toBeGreaterThan(0); + }); + expect(vi.mocked(createProduct)).not.toHaveBeenCalled(); + }); + it('renders Excel export button', async () => { render(); await act(async () => { diff --git a/src/pages/Inventory/Products/index.tsx b/src/pages/Inventory/Products/index.tsx index 0592014..555e44a 100644 --- a/src/pages/Inventory/Products/index.tsx +++ b/src/pages/Inventory/Products/index.tsx @@ -1,6 +1,6 @@ import { PlusOutlined, UploadOutlined, DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons'; import { ActionType, ModalForm, ProColumns, ProFormSelect, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components'; -import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Tag } from 'antd'; +import { Alert, Button, Card, Descriptions, Drawer, Form, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Table, Tag } from 'antd'; import { useRef, useState, useCallback } from 'react'; import { getProductSummary, @@ -19,12 +19,37 @@ import { updateProductBatch, deleteProductBatch, } from '@/services/api'; -import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo } from '@/types/api'; +import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo, YarnInfo } from '@/types/api'; type ViewLevel = 'summary' | 'colors' | 'tree'; +type YarnSide = 'warp' | 'weft'; + +interface YarnRatioRow { + key: string; + yarnId: string; + yarnName: string; + yarnColor: string; + weightGM: string; + ratio: number; +} + +const CREATE_YARN_OPTION = '__create_yarn__'; +const ratioSideLabels: Record = { + warp: '经线', + weft: '纬线', +}; +const prominentErrorTextStyle = { color: '#ff1f1f', fontWeight: 600 }; + +const getRatioTotal = (rows: YarnRatioRow[]) => rows.reduce((sum, row) => sum + Number(row.ratio || 0), 0); + +const formatWeight = (value: number) => { + const fixed = value.toFixed(4); + return fixed.replace(/\.?0+$/, ''); +}; const ProductsPage: React.FC = () => { const summaryRef = useRef(); + const [createForm] = Form.useForm(); const [viewLevel, setViewLevel] = useState('summary'); const [selectedProductName, setSelectedProductName] = useState(''); const [selectedProductId, setSelectedProductId] = useState(''); @@ -38,6 +63,12 @@ const ProductsPage: React.FC = () => { const [batchOpen, setBatchOpen] = useState(false); const [currentBatch, setCurrentBatch] = useState(null); const [yarnOpen, setYarnOpen] = useState(false); + const [yarnCreateTarget, setYarnCreateTarget] = useState(null); + const [yarnOptions, setYarnOptions] = useState([]); + const [warpYarns, setWarpYarns] = useState([]); + const [weftYarns, setWeftYarns] = useState([]); + const [ratioSubmitError, setRatioSubmitError] = useState(''); + const [weightCalcError, setWeightCalcError] = useState(''); const [unit, setUnit] = useState<'cm' | 'in'>('cm'); const formatCmSpec = (spec?: string) => { @@ -73,6 +104,257 @@ const ProductsPage: React.FC = () => { } }, []); + const loadYarnOptions = useCallback(async (keyWords?: string) => { + const res = await listYarns({ page: 1, pageSize: 50, yarnName: keyWords, status: 1 }); + if (res?.code === 0) { + setYarnOptions(res.data?.list || []); + } + }, []); + + const resetCreateProductState = useCallback(() => { + createForm.resetFields(); + setWarpYarns([]); + setWeftYarns([]); + setRatioSubmitError(''); + setWeightCalcError(''); + setYarnCreateTarget(null); + }, [createForm]); + + const handleCreateOpenChange = useCallback((open: boolean) => { + setCreateOpen(open); + if (open) { + resetCreateProductState(); + loadYarnOptions(); + } else { + resetCreateProductState(); + } + }, [loadYarnOptions, resetCreateProductState]); + + const getYarnRows = useCallback((side: YarnSide) => ( + side === 'warp' ? warpYarns : weftYarns + ), [warpYarns, weftYarns]); + + const setYarnRows = useCallback((side: YarnSide, updater: (rows: YarnRatioRow[]) => YarnRatioRow[]) => { + const wrappedUpdater = (rows: YarnRatioRow[]) => updater(rows); + if (side === 'warp') { + setWarpYarns(wrappedUpdater); + return; + } + setWeftYarns(wrappedUpdater); + }, []); + + const clearRatioFeedback = useCallback(() => { + setRatioSubmitError(''); + setWeightCalcError(''); + }, []); + + const appendYarnRatioRow = useCallback((side: YarnSide, yarn: YarnInfo) => { + setYarnRows(side, (rows) => { + if (rows.some((row) => row.yarnId === yarn.yarnId)) { + return rows; + } + const remaining = 10 - getRatioTotal(rows); + const ratio = remaining > 0 ? Math.min(10, remaining) : 1; + return [ + ...rows, + { + key: `${side}-${yarn.yarnId}-${Date.now()}`, + yarnId: yarn.yarnId, + yarnName: yarn.yarnName, + yarnColor: yarn.color || '未设置颜色', + weightGM: yarn.weightGM, + ratio, + }, + ]; + }); + clearRatioFeedback(); + }, [clearRatioFeedback, setYarnRows]); + + const handleSelectYarn = (side: YarnSide, value: string) => { + if (value === CREATE_YARN_OPTION) { + setYarnCreateTarget(side); + setYarnOpen(true); + return; + } + const yarn = yarnOptions.find((item) => item.yarnId === value); + if (yarn) { + appendYarnRatioRow(side, yarn); + } + }; + + const updateYarnRatio = (side: YarnSide, key: string, value: number | null) => { + const nextRatio = Math.max(1, Math.min(10, Math.round(Number(value || 1)))); + setYarnRows(side, (rows) => rows.map((row) => ( + row.key === key ? { ...row, ratio: nextRatio } : row + ))); + clearRatioFeedback(); + }; + + const removeYarnRatioRow = (side: YarnSide, key: string) => { + setYarnRows(side, (rows) => rows.filter((row) => row.key !== key)); + clearRatioFeedback(); + }; + + const getRatioError = (side: YarnSide, rows: YarnRatioRow[]) => { + if (rows.length === 0) { + return ''; + } + if (rows.some((row) => !row.yarnId)) { + return `${ratioSideLabels[side]}纱线不能为空`; + } + if (rows.some((row) => !Number.isInteger(row.ratio) || row.ratio < 1 || row.ratio > 10)) { + return `${ratioSideLabels[side]}使用配比必须为 1-10 的整数`; + } + const total = getRatioTotal(rows); + if (total !== 10) { + return `${ratioSideLabels[side]}配比合计需为 10,当前为 ${total}`; + } + return ''; + }; + + const getRatioErrors = () => ( + [ + getRatioError('warp', warpYarns), + getRatioError('weft', weftYarns), + ].filter(Boolean) + ); + + const serializeYarnRatio = () => { + if (warpYarns.length === 0 && weftYarns.length === 0) { + return ''; + } + return JSON.stringify({ + warp: warpYarns.map((row) => ({ + yarn_id: row.yarnId, + yarn_name: row.yarnName, + color: row.yarnColor, + ratio: row.ratio, + })), + weft: weftYarns.map((row) => ({ + yarn_id: row.yarnId, + yarn_name: row.yarnName, + color: row.yarnColor, + ratio: row.ratio, + })), + }); + }; + + const calculateSideWeight = (rows: YarnRatioRow[]) => rows.reduce((sum, row) => { + const weight = Number(row.weightGM || 0); + return sum + (Number.isFinite(weight) ? weight * row.ratio / 10 : 0); + }, 0); + + const calculateBatchWeight = () => { + const ratioErrors = getRatioErrors(); + if (warpYarns.length === 0 || weftYarns.length === 0) { + setWeightCalcError('请先添加经线和纬线纱线,并确保两边配比合计均为 10'); + return; + } + if (ratioErrors.length > 0) { + setWeightCalcError(ratioErrors.join(';')); + return; + } + const initialBatch = createForm.getFieldValue('initialBatch') || {}; + createForm.setFieldsValue({ + initialBatch: { + ...initialBatch, + warpWeightGM: formatWeight(calculateSideWeight(warpYarns)), + weftWeightGM: formatWeight(calculateSideWeight(weftYarns)), + }, + }); + setWeightCalcError(''); + message.success('已根据纱线配比计算克重'); + }; + + const renderYarnRatioTable = (side: YarnSide) => { + const rows = getYarnRows(side); + const label = ratioSideLabels[side]; + const error = getRatioError(side, rows); + const selectedYarnIds = new Set(rows.map((row) => row.yarnId)); + const options = [ + { label: `+ 创建纱线`, value: CREATE_YARN_OPTION }, + ...yarnOptions + .filter((yarn) => !selectedYarnIds.has(yarn.yarnId)) + .map((yarn) => ({ + label: `${yarn.yarnName} / ${yarn.color || '未设置颜色'}`, + value: yarn.yarnId, + })), + ]; + const total = getRatioTotal(rows); + + return ( +
+ + {label} +