From 6e3ee00a6a3de1ea3334a06ebca8be1412997cad Mon Sep 17 00:00:00 2001 From: kae Date: Sat, 4 Jul 2026 18:38:50 +0900 Subject: [PATCH] feat: support product batch inventory UI --- src/pages/Inventory/Import/index.tsx | 57 +++++- src/pages/Inventory/Products/index.test.tsx | 20 +- src/pages/Inventory/Products/index.tsx | 195 +++++++++++++++++++- src/pages/Login/index.tsx | 58 +++--- src/services/__mocks__/api.ts | 8 + src/services/api.ts | 31 +++- src/types/api.ts | 42 +++++ 7 files changed, 358 insertions(+), 53 deletions(-) diff --git a/src/pages/Inventory/Import/index.tsx b/src/pages/Inventory/Import/index.tsx index d05981a..a3d9b11 100644 --- a/src/pages/Inventory/Import/index.tsx +++ b/src/pages/Inventory/Import/index.tsx @@ -5,6 +5,55 @@ import { Upload, Card, message, Button, Alert, Space } from 'antd'; const { Dragger } = Upload; +const templateHeaders = [ + '产品名称', + '规格型号', + '颜色', + '色号', + '产品码', + '销售价', + '批次号', + '纱线配比', + '经线克重(g/m)', + '纬线克重(g/m)', + '生产工艺', + '备注', +]; + +const templateSample = [ + '示例布料', + '120cm', + '本色', + '01', + '', + '0', + '', + '{"warp":[],"weft":[]}', + '0', + '0', + '无', + '', +]; + +const escapeHtml = (value: string) => + value.replace(/&/g, '&').replace(//g, '>'); + +const downloadTemplate = () => { + const rows = [templateHeaders, templateSample] + .map((row) => `${row.map((cell) => `${escapeHtml(cell)}`).join('')}`) + .join(''); + const html = `${rows}
`; + const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = '产品导入模板.xls'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); +}; + const ImportPage: React.FC = () => { const token = localStorage.getItem('token'); @@ -14,15 +63,15 @@ const ImportPage: React.FC = () => { - + { if (info.file.status === 'done') { @@ -39,7 +88,7 @@ const ImportPage: React.FC = () => { >

点击或拖拽文件到此处上传

-

支持 .xlsx、.xls、.csv 格式

+

支持 .xlsx、.xls 格式

diff --git a/src/pages/Inventory/Products/index.test.tsx b/src/pages/Inventory/Products/index.test.tsx index 7faf4c3..f6a80e5 100644 --- a/src/pages/Inventory/Products/index.test.tsx +++ b/src/pages/Inventory/Products/index.test.tsx @@ -9,6 +9,11 @@ import { smokeTest } from '../../../../test/smoke'; import Page from './index'; import { createProduct, getProductSummary } from '@/services/api'; +const clickDialogSubmit = async (user: ReturnType, dialog: HTMLElement) => { + const submitBtn = within(dialog).getByRole('button', { name: /确\s*定|确\s*认|提交|OK/i }); + await user.click(submitBtn); +}; + describe('Inventory / Products page', () => { beforeEach(() => { vi.clearAllMocks(); @@ -56,13 +61,7 @@ describe('Inventory / Products page', () => { const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ }); await user.type(nameInput, '测试产品'); - // Click the submit/OK button – find the non-close button in the dialog footer - const buttons = within(dialog).getAllByRole('button'); - const submitBtn = buttons.find((b) => { - const text = b.textContent?.trim() ?? ''; - return text && !text.includes('×') && !text.includes('✕') && !/取/.test(text) && !/重/.test(text) && !/Cancel/i.test(text); - }); - if (submitBtn) await user.click(submitBtn); + await clickDialogSubmit(user, dialog); await waitFor(() => { expect(vi.mocked(createProduct)).toHaveBeenCalled(); @@ -133,12 +132,7 @@ describe('Inventory / Products page', () => { const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ }); await user.type(nameInput, '新布料'); - const buttons = within(dialog).getAllByRole('button'); - const submitBtn = buttons.find((b) => { - const text = b.textContent?.trim() ?? ''; - return text && !text.includes('×') && !text.includes('✕') && !/取/.test(text) && !/重/.test(text) && !/Cancel/i.test(text); - }); - if (submitBtn) await user.click(submitBtn); + await clickDialogSubmit(user, dialog); await waitFor(() => { expect(vi.mocked(createProduct)).toHaveBeenCalled(); diff --git a/src/pages/Inventory/Products/index.tsx b/src/pages/Inventory/Products/index.tsx index 41a5eea..0592014 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, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components'; -import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Space, Tag } from 'antd'; +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 { useRef, useState, useCallback } from 'react'; import { getProductSummary, @@ -9,9 +9,17 @@ import { createProduct, updateProduct, deleteProduct, + exportProducts, savePans, + listYarns, + createYarn, + listSuppliers, + listProductBatches, + createProductBatch, + updateProductBatch, + deleteProductBatch, } from '@/services/api'; -import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput } from '@/types/api'; +import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo } from '@/types/api'; type ViewLevel = 'summary' | 'colors' | 'tree'; @@ -26,10 +34,39 @@ const ProductsPage: React.FC = () => { const [editTreeDrawer, setEditTreeDrawer] = useState(false); const [currentColor, setCurrentColor] = useState(null); const [panInputs, setPanInputs] = useState([]); + const [batches, setBatches] = useState([]); + const [batchOpen, setBatchOpen] = useState(false); + const [currentBatch, setCurrentBatch] = useState(null); + const [yarnOpen, setYarnOpen] = useState(false); + const [unit, setUnit] = useState<'cm' | 'in'>('cm'); + + const formatCmSpec = (spec?: string) => { + if (!spec || unit === 'cm') return spec; + return spec.replace(/(\d+(?:\.\d+)?)\s*cm/gi, (_, n) => `${(Number(n) / 2.54).toFixed(2)}in`); + }; + + const handleExport = async () => { + try { + const data = await exportProducts(); + const blob = data instanceof Blob ? data : new Blob([data as BlobPart]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `products-${Date.now()}.xlsx`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch { + message.error('导出失败'); + } + }; const loadTree = useCallback(async (productId: string) => { const res = await getProductTree(productId); if (res?.code === 0) { + const batchRes = await listProductBatches(productId); + setBatches(batchRes?.data?.list || []); setTreeData(res.data); setSelectedProductId(productId); setViewLevel('tree'); @@ -39,7 +76,7 @@ const ProductsPage: React.FC = () => { // ── Panel 1: Product Summary ── const summaryColumns: ProColumns[] = [ { title: '产品名称', dataIndex: 'productName', ellipsis: true }, - { title: '规格型号', dataIndex: 'spec', search: false }, + { title: '规格型号', dataIndex: 'spec', search: false, render: (_, r) => formatCmSpec(r.spec) }, { title: '颜色数', dataIndex: 'colorCount', search: false }, { title: '总盘数', dataIndex: 'totalPanCount', search: false }, { title: '总匹数', dataIndex: 'totalBoltCount', search: false }, @@ -57,7 +94,10 @@ const ProductsPage: React.FC = () => { // ── Panel 2: Color Detail ── const colorColumns: ProColumns[] = [ { title: '颜色', dataIndex: 'color' }, - { title: '规格', dataIndex: 'spec', search: false }, + { title: '色号', dataIndex: 'colorNo', search: false }, + { title: '产品码', dataIndex: 'productCode', search: false, copyable: true }, + { title: '规格', dataIndex: 'spec', search: false, render: (_, r) => formatCmSpec(r.spec) }, + { title: '批次数', dataIndex: 'batchCount', search: false }, { title: '盘数', dataIndex: 'panCount', search: false }, { title: '匹数', dataIndex: 'boltCount', search: false }, { title: '库存(m)', dataIndex: 'totalLengthM', search: false }, @@ -83,14 +123,48 @@ const ProductsPage: React.FC = () => { return (
- {product.spec} + {product.productCode || '-'} + {product.colorNo || '-'} + {formatCmSpec(product.spec)} {totalPanCount} {totalBoltCount} {totalLengthM}m + { setCurrentBatch(null); setBatchOpen(true); }}>新增批次}> + ( + { setCurrentBatch(batch); setBatchOpen(true); }}>编辑, + { + const res = await deleteProductBatch(selectedProductId, batch.batchId); + if (res?.code === 0) { + message.success('删除成功'); + loadTree(selectedProductId); + } + }} + > + 删除 + , + ]} + > + {batch.batchNo}{Number(batch.warpWeightGM || 0) + Number(batch.weftWeightGM || 0)}g/m} + description={batch.productionProcess || '无'} + /> + + )} + /> + {pans.map((pan) => ( - {pan.position || '未设置位置'}}> + {pan.batchNo || '未归属批次'}{pan.position || '未设置位置'}}> { setPanInputs(pans.map((p) => ({ name: p.name, position: p.position, + batchId: p.batchId, boltLengths: p.bolts.map((b) => b.lengthM), }))); setEditTreeDrawer(true); @@ -144,9 +219,10 @@ const ProductsPage: React.FC = () => { return { data: res?.data?.list || [], success: res?.code === 0, total: res?.data?.list?.length || 0 }; }} toolBarRender={() => [ + setUnit(value as 'cm' | 'in')} />, , , - , + , ]} /> )} @@ -172,7 +248,19 @@ const ProductsPage: React.FC = () => { open={createOpen} onOpenChange={setCreateOpen} onFinish={async (values) => { - const res = await createProduct({ ...values, salesPrice: String(values.salesPrice || '0') }); + const selectedYarnIds = values.selectedYarnIds || []; + const initialBatch = values.initialBatch || {}; + const yarnRatio = initialBatch.yarnRatio || (selectedYarnIds.length ? JSON.stringify({ + warp: selectedYarnIds.map((yarnId: string) => ({ yarn_id: yarnId, ratio: 1 })), + weft: [], + }) : ''); + const productValues = { ...values }; + delete productValues.selectedYarnIds; + const res = await createProduct({ + ...productValues, + salesPrice: String(values.salesPrice || '0'), + initialBatch: { ...initialBatch, yarnRatio }, + }); if (res?.code === 0) { message.success('创建成功'); summaryRef.current?.reload(); return true; } message.error(res?.msg || '创建失败'); return false; @@ -180,8 +268,23 @@ const ProductsPage: React.FC = () => { > - + + + { + const res = await listYarns({ page: 1, pageSize: 50, yarnName: keyWords }); + return (res?.data?.list || []).map((y) => ({ label: `${y.yarnName} / ${y.color}`, value: y.yarnId })); + }} + fieldProps={{ showSearch: true, mode: 'multiple' }} + /> + + + + + @@ -194,6 +297,8 @@ const ProductsPage: React.FC = () => { productName: currentColor.productName, spec: currentColor.spec, color: currentColor.color, + colorNo: currentColor.colorNo, + productCode: currentColor.productCode, salesPrice: currentColor.salesPrice, } : undefined} onFinish={async (values) => { @@ -205,12 +310,71 @@ const ProductsPage: React.FC = () => { }} > + + + { + const res = currentBatch + ? await updateProductBatch(selectedProductId, currentBatch.batchId, values) + : await createProductBatch(selectedProductId, values); + if (res?.code === 0) { + message.success('保存成功'); + loadTree(selectedProductId); + return true; + } + message.error(res?.msg || '保存失败'); + return false; + }} + > + {currentBatch && } + + + + + + + + { + const res = await createYarn(values); + if (res?.code === 0) { + message.success('创建成功'); + return true; + } + message.error(res?.msg || '创建失败'); + return false; + }} + > + + + + { + const res = await listSuppliers({ page: 1, pageSize: 50, supplierName: keyWords, status: 1 }); + return (res?.data?.list || []).map((supplier) => ({ label: supplier.supplierName, value: supplier.supplierId })); + }} + fieldProps={{ showSearch: true }} + /> + + + + {/* Edit Pans/Bolts Drawer */} { }} style={{ width: 120 }} /> +