feat:improve product yarn ratio workflow
## Summary - Reworked the product creation modal around warp/weft yarn ratio tables. - Added yarn selection, create-yarn entry points, ratio validation feedback, and ratio-based weight calculation. - Updated frontend service/types coverage and tests for the product workflow. ## Why The product creation flow previously did not guide users through structured warp/weft yarn ratios, making it easy to enter incomplete or invalid ratio data. The new flow makes the yarn composition explicit and blocks invalid product submission. ## Impact Users can select existing yarns or create yarns while adding a product, edit ratio values per row, and calculate batch weights once both yarn sides are valid. ## Validation - `pnpm test` - `pnpm build`
This commit is contained in:
commit
066724318a
@ -5,6 +5,55 @@ import { Upload, Card, message, Button, Alert, Space } from 'antd';
|
|||||||
|
|
||||||
const { Dragger } = Upload;
|
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, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const rows = [templateHeaders, templateSample]
|
||||||
|
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`)
|
||||||
|
.join('');
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf-8" /></head><body><table>${rows}</table></body></html>`;
|
||||||
|
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 ImportPage: React.FC = () => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
@ -14,15 +63,15 @@ const ImportPage: React.FC = () => {
|
|||||||
<Space direction="vertical" size={20} style={{ display: 'flex' }}>
|
<Space direction="vertical" size={20} style={{ display: 'flex' }}>
|
||||||
<Alert
|
<Alert
|
||||||
message="导入说明"
|
message="导入说明"
|
||||||
description="请按照模板格式准备 Excel 文件,必填字段:产品名称、库存数量。支持 .xlsx 和 .xls 格式。"
|
description="请按照模板格式准备 Excel 文件,必填字段:产品名称。可选字段:规格型号、颜色、色号、产品码、纱线配比、经纬克重、生产工艺。支持 .xlsx 和 .xls 格式。"
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
<Button icon={<DownloadOutlined />}>下载导入模板</Button>
|
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>下载导入模板</Button>
|
||||||
<Dragger
|
<Dragger
|
||||||
name="file"
|
name="file"
|
||||||
action="/api/v1/inventory/products/import"
|
action="/api/v1/inventory/products/import"
|
||||||
accept=".xlsx,.xls,.csv"
|
accept=".xlsx,.xls"
|
||||||
headers={{ Authorization: `Bearer ${token}` }}
|
headers={{ Authorization: `Bearer ${token}` }}
|
||||||
onChange={(info) => {
|
onChange={(info) => {
|
||||||
if (info.file.status === 'done') {
|
if (info.file.status === 'done') {
|
||||||
@ -39,7 +88,7 @@ const ImportPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||||
<p className="ant-upload-hint">支持 .xlsx、.xls、.csv 格式</p>
|
<p className="ant-upload-hint">支持 .xlsx、.xls 格式</p>
|
||||||
</Dragger>
|
</Dragger>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -7,7 +7,12 @@ vi.mock('@/services/api');
|
|||||||
|
|
||||||
import { smokeTest } from '../../../../test/smoke';
|
import { smokeTest } from '../../../../test/smoke';
|
||||||
import Page from './index';
|
import Page from './index';
|
||||||
import { createProduct, getProductSummary } from '@/services/api';
|
import { createProduct, getProductSummary, listYarns } from '@/services/api';
|
||||||
|
|
||||||
|
const clickDialogSubmit = async (user: ReturnType<typeof userEvent.setup>, dialog: HTMLElement) => {
|
||||||
|
const submitBtn = within(dialog).getByRole('button', { name: /确\s*定|确\s*认|提交|OK/i });
|
||||||
|
await user.click(submitBtn);
|
||||||
|
};
|
||||||
|
|
||||||
describe('Inventory / Products page', () => {
|
describe('Inventory / Products page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -56,19 +61,111 @@ describe('Inventory / Products page', () => {
|
|||||||
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
|
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
|
||||||
await user.type(nameInput, '测试产品');
|
await user.type(nameInput, '测试产品');
|
||||||
|
|
||||||
// Click the submit/OK button – find the non-close button in the dialog footer
|
await clickDialogSubmit(user, dialog);
|
||||||
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 waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(vi.mocked(createProduct)).toHaveBeenCalled();
|
expect(vi.mocked(createProduct)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders yarn ratio tables and blocks weight calculation until both sides are complete', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Page />);
|
||||||
|
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(<Page />);
|
||||||
|
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(<Page />);
|
||||||
|
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 () => {
|
it('renders Excel export button', async () => {
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@ -133,12 +230,7 @@ describe('Inventory / Products page', () => {
|
|||||||
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
|
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
|
||||||
await user.type(nameInput, '新布料');
|
await user.type(nameInput, '新布料');
|
||||||
|
|
||||||
const buttons = within(dialog).getAllByRole('button');
|
await clickDialogSubmit(user, dialog);
|
||||||
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 waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(vi.mocked(createProduct)).toHaveBeenCalled();
|
expect(vi.mocked(createProduct)).toHaveBeenCalled();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { PlusOutlined, UploadOutlined, DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
import { PlusOutlined, UploadOutlined, DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
import { ActionType, ModalForm, ProColumns, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
|
import { ActionType, ModalForm, ProColumns, ProFormSelect, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
|
||||||
import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, 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 { useRef, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
getProductSummary,
|
getProductSummary,
|
||||||
@ -9,14 +9,47 @@ import {
|
|||||||
createProduct,
|
createProduct,
|
||||||
updateProduct,
|
updateProduct,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
|
exportProducts,
|
||||||
savePans,
|
savePans,
|
||||||
|
listYarns,
|
||||||
|
createYarn,
|
||||||
|
listSuppliers,
|
||||||
|
listProductBatches,
|
||||||
|
createProductBatch,
|
||||||
|
updateProductBatch,
|
||||||
|
deleteProductBatch,
|
||||||
} from '@/services/api';
|
} from '@/services/api';
|
||||||
import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput } from '@/types/api';
|
import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo, YarnInfo } from '@/types/api';
|
||||||
|
|
||||||
type ViewLevel = 'summary' | 'colors' | 'tree';
|
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<YarnSide, string> = {
|
||||||
|
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 ProductsPage: React.FC = () => {
|
||||||
const summaryRef = useRef<ActionType>();
|
const summaryRef = useRef<ActionType>();
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
const [viewLevel, setViewLevel] = useState<ViewLevel>('summary');
|
const [viewLevel, setViewLevel] = useState<ViewLevel>('summary');
|
||||||
const [selectedProductName, setSelectedProductName] = useState('');
|
const [selectedProductName, setSelectedProductName] = useState('');
|
||||||
const [selectedProductId, setSelectedProductId] = useState('');
|
const [selectedProductId, setSelectedProductId] = useState('');
|
||||||
@ -26,20 +59,306 @@ const ProductsPage: React.FC = () => {
|
|||||||
const [editTreeDrawer, setEditTreeDrawer] = useState(false);
|
const [editTreeDrawer, setEditTreeDrawer] = useState(false);
|
||||||
const [currentColor, setCurrentColor] = useState<ColorDetailItem | null>(null);
|
const [currentColor, setCurrentColor] = useState<ColorDetailItem | null>(null);
|
||||||
const [panInputs, setPanInputs] = useState<PanInput[]>([]);
|
const [panInputs, setPanInputs] = useState<PanInput[]>([]);
|
||||||
|
const [batches, setBatches] = useState<ProductBatchInfo[]>([]);
|
||||||
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
|
const [currentBatch, setCurrentBatch] = useState<ProductBatchInfo | null>(null);
|
||||||
|
const [yarnOpen, setYarnOpen] = useState(false);
|
||||||
|
const [yarnCreateTarget, setYarnCreateTarget] = useState<YarnSide | null>(null);
|
||||||
|
const [yarnOptions, setYarnOptions] = useState<YarnInfo[]>([]);
|
||||||
|
const [warpYarns, setWarpYarns] = useState<YarnRatioRow[]>([]);
|
||||||
|
const [weftYarns, setWeftYarns] = useState<YarnRatioRow[]>([]);
|
||||||
|
const [ratioSubmitError, setRatioSubmitError] = useState('');
|
||||||
|
const [weightCalcError, setWeightCalcError] = useState('');
|
||||||
|
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 loadTree = useCallback(async (productId: string) => {
|
||||||
const res = await getProductTree(productId);
|
const res = await getProductTree(productId);
|
||||||
if (res?.code === 0) {
|
if (res?.code === 0) {
|
||||||
|
const batchRes = await listProductBatches(productId);
|
||||||
|
setBatches(batchRes?.data?.list || []);
|
||||||
setTreeData(res.data);
|
setTreeData(res.data);
|
||||||
setSelectedProductId(productId);
|
setSelectedProductId(productId);
|
||||||
setViewLevel('tree');
|
setViewLevel('tree');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<Select
|
||||||
|
aria-label={`添加${label}纱线`}
|
||||||
|
showSearch
|
||||||
|
value={undefined}
|
||||||
|
placeholder={`添加${label}纱线`}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
filterOption={false}
|
||||||
|
options={options}
|
||||||
|
onSearch={loadYarnOptions}
|
||||||
|
onFocus={() => loadYarnOptions()}
|
||||||
|
onChange={(value) => handleSelectYarn(side, value)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Table<YarnRatioRow>
|
||||||
|
rowKey="key"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={rows}
|
||||||
|
locale={{ emptyText: '尚未添加纱线' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '纱线名称/纱线颜色',
|
||||||
|
dataIndex: 'yarnName',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{row.yarnName}</span>
|
||||||
|
<span style={{ color: 'var(--ant-color-text-secondary)' }}>/ {row.yarnColor}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '使用配比(合计 10)',
|
||||||
|
dataIndex: 'ratio',
|
||||||
|
width: 180,
|
||||||
|
render: (_, row) => (
|
||||||
|
<InputNumber
|
||||||
|
aria-label={`${label}${row.yarnName}使用配比`}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
value={row.ratio}
|
||||||
|
onChange={(value) => updateYarnRatio(side, row.key, value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" onClick={() => removeYarnRatioRow(side, row.key)} style={{ padding: 0, height: 'auto' }}>移除</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
footer={() => (
|
||||||
|
<span style={error ? prominentErrorTextStyle : undefined}>
|
||||||
|
合计 {total} / 10{rows.length === 0 ? '(未添加)' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div role="alert" style={{ marginTop: 6, ...prominentErrorTextStyle }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Panel 1: Product Summary ──
|
// ── Panel 1: Product Summary ──
|
||||||
const summaryColumns: ProColumns<ProductSummaryItem>[] = [
|
const summaryColumns: ProColumns<ProductSummaryItem>[] = [
|
||||||
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
{ 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: 'colorCount', search: false },
|
||||||
{ title: '总盘数', dataIndex: 'totalPanCount', search: false },
|
{ title: '总盘数', dataIndex: 'totalPanCount', search: false },
|
||||||
{ title: '总匹数', dataIndex: 'totalBoltCount', search: false },
|
{ title: '总匹数', dataIndex: 'totalBoltCount', search: false },
|
||||||
@ -57,7 +376,10 @@ const ProductsPage: React.FC = () => {
|
|||||||
// ── Panel 2: Color Detail ──
|
// ── Panel 2: Color Detail ──
|
||||||
const colorColumns: ProColumns<ColorDetailItem>[] = [
|
const colorColumns: ProColumns<ColorDetailItem>[] = [
|
||||||
{ title: '颜色', dataIndex: 'color' },
|
{ 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: 'panCount', search: false },
|
||||||
{ title: '匹数', dataIndex: 'boltCount', search: false },
|
{ title: '匹数', dataIndex: 'boltCount', search: false },
|
||||||
{ title: '库存(m)', dataIndex: 'totalLengthM', search: false },
|
{ title: '库存(m)', dataIndex: 'totalLengthM', search: false },
|
||||||
@ -83,14 +405,48 @@ const ProductsPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Descriptions title={`${product.productName} - ${product.color}`} bordered size="small" column={4} style={{ marginBottom: 16 }}>
|
<Descriptions title={`${product.productName} - ${product.color}`} bordered size="small" column={4} style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="规格">{product.spec}</Descriptions.Item>
|
<Descriptions.Item label="产品码">{product.productCode || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="色号">{product.colorNo || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="规格">{formatCmSpec(product.spec)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="总盘数">{totalPanCount}</Descriptions.Item>
|
<Descriptions.Item label="总盘数">{totalPanCount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="总匹数">{totalBoltCount}</Descriptions.Item>
|
<Descriptions.Item label="总匹数">{totalBoltCount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="总米数">{totalLengthM}m</Descriptions.Item>
|
<Descriptions.Item label="总米数">{totalLengthM}m</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
<Card size="small" title="批次" style={{ marginBottom: 16 }} extra={<Button size="small" type="primary" onClick={() => { setCurrentBatch(null); setBatchOpen(true); }}>新增批次</Button>}>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={batches}
|
||||||
|
renderItem={(batch) => (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
<a key="edit" onClick={() => { setCurrentBatch(batch); setBatchOpen(true); }}>编辑</a>,
|
||||||
|
<Popconfirm
|
||||||
|
key="del"
|
||||||
|
title="确认删除?"
|
||||||
|
disabled={batch.batchNo.endsWith('-B001')}
|
||||||
|
onConfirm={async () => {
|
||||||
|
const res = await deleteProductBatch(selectedProductId, batch.batchId);
|
||||||
|
if (res?.code === 0) {
|
||||||
|
message.success('删除成功');
|
||||||
|
loadTree(selectedProductId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a style={{ color: batch.batchNo.endsWith('-B001') ? 'var(--ant-color-text-disabled)' : 'var(--ant-color-error)' }}>删除</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={<Space><span>{batch.batchNo}</span><Tag>{Number(batch.warpWeightGM || 0) + Number(batch.weftWeightGM || 0)}g/m</Tag></Space>}
|
||||||
|
description={batch.productionProcess || '无'}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
{pans.map((pan) => (
|
{pans.map((pan) => (
|
||||||
<Card key={pan.panId} size="small" title={`盘${pan.name}`} extra={<Tag color="blue">{pan.position || '未设置位置'}</Tag>}>
|
<Card key={pan.panId} size="small" title={`盘${pan.name}`} extra={<Space><Tag>{pan.batchNo || '未归属批次'}</Tag><Tag color="blue">{pan.position || '未设置位置'}</Tag></Space>}>
|
||||||
<List
|
<List
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={pan.bolts}
|
dataSource={pan.bolts}
|
||||||
@ -106,6 +462,7 @@ const ProductsPage: React.FC = () => {
|
|||||||
setPanInputs(pans.map((p) => ({
|
setPanInputs(pans.map((p) => ({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
position: p.position,
|
position: p.position,
|
||||||
|
batchId: p.batchId,
|
||||||
boltLengths: p.bolts.map((b) => b.lengthM),
|
boltLengths: p.bolts.map((b) => b.lengthM),
|
||||||
})));
|
})));
|
||||||
setEditTreeDrawer(true);
|
setEditTreeDrawer(true);
|
||||||
@ -144,9 +501,10 @@ const ProductsPage: React.FC = () => {
|
|||||||
return { data: res?.data?.list || [], success: res?.code === 0, total: res?.data?.list?.length || 0 };
|
return { data: res?.data?.list || [], success: res?.code === 0, total: res?.data?.list?.length || 0 };
|
||||||
}}
|
}}
|
||||||
toolBarRender={() => [
|
toolBarRender={() => [
|
||||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新增产品</Button>,
|
<Segmented key="unit" size="small" value={unit} options={[{ label: 'cm', value: 'cm' }, { label: 'in', value: 'in' }]} onChange={(value) => setUnit(value as 'cm' | 'in')} />,
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => handleCreateOpenChange(true)}>新增产品</Button>,
|
||||||
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>,
|
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>,
|
||||||
<Button key="export" icon={<DownloadOutlined />}>Excel导出</Button>,
|
<Button key="export" icon={<DownloadOutlined />} onClick={handleExport}>Excel导出</Button>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -170,9 +528,24 @@ const ProductsPage: React.FC = () => {
|
|||||||
<ModalForm
|
<ModalForm
|
||||||
title="新增产品"
|
title="新增产品"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
form={createForm}
|
||||||
|
onOpenChange={handleCreateOpenChange}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
const res = await createProduct({ ...values, salesPrice: String(values.salesPrice || '0') });
|
const ratioErrors = getRatioErrors();
|
||||||
|
if (ratioErrors.length > 0) {
|
||||||
|
const errorText = ratioErrors.join(';');
|
||||||
|
setRatioSubmitError(errorText);
|
||||||
|
message.error(errorText);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const initialBatch = values.initialBatch || {};
|
||||||
|
const yarnRatio = serializeYarnRatio();
|
||||||
|
const productValues = { ...values };
|
||||||
|
const res = await createProduct({
|
||||||
|
...productValues,
|
||||||
|
salesPrice: String(values.salesPrice || '0'),
|
||||||
|
initialBatch: { ...initialBatch, yarnRatio },
|
||||||
|
});
|
||||||
if (res?.code === 0) { message.success('创建成功'); summaryRef.current?.reload(); return true; }
|
if (res?.code === 0) { message.success('创建成功'); summaryRef.current?.reload(); return true; }
|
||||||
message.error(res?.msg || '创建失败');
|
message.error(res?.msg || '创建失败');
|
||||||
return false;
|
return false;
|
||||||
@ -180,9 +553,28 @@ const ProductsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
|
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
|
||||||
<ProFormText name="spec" label="规格型号" />
|
<ProFormText name="spec" label="规格型号" />
|
||||||
<ProFormText name="color" label="颜色" />
|
<ProFormText name="color" label="颜色" initialValue="本色" />
|
||||||
|
<ProFormText name="colorNo" label="色号" />
|
||||||
<ProFormText name="salesPrice" label="销售价" />
|
<ProFormText name="salesPrice" label="销售价" />
|
||||||
<ProFormTextArea name="remark" label="备注" />
|
<div style={{ marginBottom: 8, fontWeight: 600 }}>纱线配比</div>
|
||||||
|
{ratioSubmitError && (
|
||||||
|
<Alert type="error" showIcon message={<span style={prominentErrorTextStyle}>{ratioSubmitError}</span>} style={{ marginBottom: 12 }} />
|
||||||
|
)}
|
||||||
|
{renderYarnRatioTable('warp')}
|
||||||
|
{renderYarnRatioTable('weft')}
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 600 }}>克重数据</div>
|
||||||
|
<Button type="link" onClick={calculateBatchWeight} style={{ padding: 0, height: 'auto', marginBottom: 6 }}>
|
||||||
|
根据纱线配比计算
|
||||||
|
</Button>
|
||||||
|
{weightCalcError && (
|
||||||
|
<div role="alert" style={{ marginBottom: 8, ...prominentErrorTextStyle }}>
|
||||||
|
{weightCalcError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ProFormText name={['initialBatch', 'warpWeightGM']} label="经线克重(g/m)" rules={[{ pattern: /^\d+(?:\.\d+)?$/, message: '请输入数字' }]} />
|
||||||
|
<ProFormText name={['initialBatch', 'weftWeightGM']} label="纬线克重(g/m)" rules={[{ pattern: /^\d+(?:\.\d+)?$/, message: '请输入数字' }]} />
|
||||||
|
<ProFormText name={['initialBatch', 'productionProcess']} label="生产工艺" initialValue="无" />
|
||||||
|
<ProFormText name="remark" label="备注" />
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
|
|
||||||
{/* Edit product modal */}
|
{/* Edit product modal */}
|
||||||
@ -194,6 +586,8 @@ const ProductsPage: React.FC = () => {
|
|||||||
productName: currentColor.productName,
|
productName: currentColor.productName,
|
||||||
spec: currentColor.spec,
|
spec: currentColor.spec,
|
||||||
color: currentColor.color,
|
color: currentColor.color,
|
||||||
|
colorNo: currentColor.colorNo,
|
||||||
|
productCode: currentColor.productCode,
|
||||||
salesPrice: currentColor.salesPrice,
|
salesPrice: currentColor.salesPrice,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
@ -205,12 +599,95 @@ const ProductsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
|
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
|
||||||
|
<ProFormText name="productCode" label="产品码" disabled />
|
||||||
<ProFormText name="spec" label="规格型号" />
|
<ProFormText name="spec" label="规格型号" />
|
||||||
<ProFormText name="color" label="颜色" />
|
<ProFormText name="color" label="颜色" />
|
||||||
|
<ProFormText name="colorNo" label="色号" />
|
||||||
<ProFormText name="salesPrice" label="销售价" />
|
<ProFormText name="salesPrice" label="销售价" />
|
||||||
<ProFormTextArea name="remark" label="备注" />
|
<ProFormTextArea name="remark" label="备注" />
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
|
|
||||||
|
<ModalForm
|
||||||
|
title={currentBatch ? '编辑批次' : '新增批次'}
|
||||||
|
open={batchOpen}
|
||||||
|
onOpenChange={setBatchOpen}
|
||||||
|
initialValues={currentBatch || { productionProcess: '无' }}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
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 && <ProFormText name="batchNo" label="批次号" disabled />}
|
||||||
|
<ProFormTextArea name="yarnRatio" label="纱线配比" />
|
||||||
|
<ProFormText name="warpWeightGM" label="经线克重(g/m)" />
|
||||||
|
<ProFormText name="weftWeightGM" label="纬线克重(g/m)" />
|
||||||
|
<ProFormTextArea name="productionProcess" label="生产工艺" />
|
||||||
|
<ProFormTextArea name="remark" label="备注" />
|
||||||
|
</ModalForm>
|
||||||
|
|
||||||
|
<ModalForm
|
||||||
|
title="新增纱线"
|
||||||
|
open={yarnOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setYarnOpen(open);
|
||||||
|
if (!open) setYarnCreateTarget(null);
|
||||||
|
}}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
const res = await createYarn(values);
|
||||||
|
if (res?.code === 0) {
|
||||||
|
const createdYarnId = res.data?.id || '';
|
||||||
|
const createdYarn: YarnInfo = {
|
||||||
|
yarnId: createdYarnId,
|
||||||
|
yarnName: values.yarnName,
|
||||||
|
color: values.color || '原纱本色',
|
||||||
|
weightGM: values.weightGM,
|
||||||
|
supplierId: values.supplierId,
|
||||||
|
dyeFactory: values.dyeFactory,
|
||||||
|
remark: values.remark,
|
||||||
|
};
|
||||||
|
if (createdYarn.yarnId) {
|
||||||
|
setYarnOptions((options) => [
|
||||||
|
createdYarn,
|
||||||
|
...options.filter((item) => item.yarnId !== createdYarn.yarnId),
|
||||||
|
]);
|
||||||
|
if (yarnCreateTarget) {
|
||||||
|
appendYarnRatioRow(yarnCreateTarget, createdYarn);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadYarnOptions();
|
||||||
|
}
|
||||||
|
message.success('创建成功');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
message.error(res?.msg || '创建失败');
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText name="yarnName" label="纱线名称" rules={[{ required: true }]} />
|
||||||
|
<ProFormText name="color" label="颜色" initialValue="原纱本色" />
|
||||||
|
<ProFormText name="weightGM" label="纱线克重(g/m)" rules={[{ required: true }, { pattern: /^\d+(?:\.\d+)?$/, message: '请输入数字' }]} />
|
||||||
|
<ProFormSelect
|
||||||
|
name="supplierId"
|
||||||
|
label="供应商"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
request={async ({ keyWords }) => {
|
||||||
|
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 }}
|
||||||
|
/>
|
||||||
|
<ProFormText name="dyeFactory" label="染纱厂" initialValue="无" />
|
||||||
|
<ProFormTextArea name="remark" label="备注" />
|
||||||
|
</ModalForm>
|
||||||
|
|
||||||
{/* Edit Pans/Bolts Drawer */}
|
{/* Edit Pans/Bolts Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title="编辑盘/匹"
|
title="编辑盘/匹"
|
||||||
@ -259,6 +736,18 @@ const ProductsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="批次"
|
||||||
|
value={pan.batchId}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
allowClear
|
||||||
|
options={batches.map((batch) => ({ label: batch.batchNo, value: batch.batchId }))}
|
||||||
|
onChange={(value) => {
|
||||||
|
const next = [...panInputs];
|
||||||
|
next[pi] = { ...next[pi], batchId: value || undefined };
|
||||||
|
setPanInputs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
{pan.boltLengths?.map((len, bi) => (
|
{pan.boltLengths?.map((len, bi) => (
|
||||||
<Space key={bi} style={{ display: 'flex', marginBottom: 4 }}>
|
<Space key={bi} style={{ display: 'flex', marginBottom: 4 }}>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { history, useModel, request } from '@umijs/max';
|
import { history, useModel, request } from '@umijs/max';
|
||||||
import { Button, Form, Input, message, Select } from 'antd';
|
import { Button, Form, Input, message, Select } from 'antd';
|
||||||
import { LockOutlined, UserOutlined, BankOutlined } from '@ant-design/icons';
|
import { LockOutlined, UserOutlined, BankOutlined } from '@ant-design/icons';
|
||||||
@ -6,6 +6,7 @@ import { getUserInfo, login } from '@/services/api';
|
|||||||
import type { LoginParams } from '@/types/api';
|
import type { LoginParams } from '@/types/api';
|
||||||
|
|
||||||
const USER_CACHE_KEY = 'muyu_user_state';
|
const USER_CACHE_KEY = 'muyu_user_state';
|
||||||
|
const DEFAULT_TENANT_OPTION = { label: '默认租户(t_default_001)', value: 't_default_001' };
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const { setInitialState } = useModel('@@initialState');
|
const { setInitialState } = useModel('@@initialState');
|
||||||
@ -14,32 +15,41 @@ const LoginPage: React.FC = () => {
|
|||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const loadTenantOptions = useCallback(async (keyword = '') => {
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await request('/api/v1/public/tenants/search', {
|
||||||
|
method: 'GET',
|
||||||
|
params: { keyword, limit: 20 },
|
||||||
|
});
|
||||||
|
if (res?.code === 0 && res.data?.list) {
|
||||||
|
const options = res.data.list.map((t: { id: string; name: string }) => ({
|
||||||
|
label: `${t.name}(${t.id})`,
|
||||||
|
value: t.id,
|
||||||
|
}));
|
||||||
|
setTenantOptions(options.length ? options : [DEFAULT_TENANT_OPTION]);
|
||||||
|
} else {
|
||||||
|
setTenantOptions([DEFAULT_TENANT_OPTION]);
|
||||||
|
message.error('获取公司列表失败,请稍后重试');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTenantOptions([DEFAULT_TENANT_OPTION]);
|
||||||
|
message.error('获取公司列表失败,请检查网络连接');
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTenantOptions();
|
||||||
|
}, [loadTenantOptions]);
|
||||||
|
|
||||||
const handleTenantSearch = useCallback((keyword: string) => {
|
const handleTenantSearch = useCallback((keyword: string) => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(() => {
|
||||||
setSearching(true);
|
loadTenantOptions(keyword);
|
||||||
try {
|
|
||||||
const res = await request('/api/v1/public/tenants/search', {
|
|
||||||
method: 'GET',
|
|
||||||
params: { keyword, limit: 20 },
|
|
||||||
});
|
|
||||||
if (res?.code === 0 && res.data?.list) {
|
|
||||||
setTenantOptions(
|
|
||||||
res.data.list.map((t: { id: string; name: string }) => ({
|
|
||||||
label: `${t.name}(${t.id})`,
|
|
||||||
value: t.id,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
message.error('获取公司列表失败,请稍后重试');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error('获取公司列表失败,请检查网络连接');
|
|
||||||
} finally {
|
|
||||||
setSearching(false);
|
|
||||||
}
|
|
||||||
}, 300);
|
}, 300);
|
||||||
}, []);
|
}, [loadTenantOptions]);
|
||||||
|
|
||||||
const handleSubmit = async (values: LoginParams) => {
|
const handleSubmit = async (values: LoginParams) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@ -60,6 +60,14 @@ export const exportProducts = vi.fn(ok);
|
|||||||
export const getProductSummary = vi.fn(ok);
|
export const getProductSummary = vi.fn(ok);
|
||||||
export const getColorDetail = vi.fn(ok);
|
export const getColorDetail = vi.fn(ok);
|
||||||
export const getProductTree = vi.fn(ok);
|
export const getProductTree = vi.fn(ok);
|
||||||
|
export const listYarns = vi.fn(ok);
|
||||||
|
export const createYarn = vi.fn(ok);
|
||||||
|
export const updateYarn = vi.fn(ok);
|
||||||
|
export const deleteYarn = vi.fn(ok);
|
||||||
|
export const listProductBatches = vi.fn(ok);
|
||||||
|
export const createProductBatch = vi.fn(ok);
|
||||||
|
export const updateProductBatch = vi.fn(ok);
|
||||||
|
export const deleteProductBatch = vi.fn(ok);
|
||||||
export const listPans = vi.fn(ok);
|
export const listPans = vi.fn(ok);
|
||||||
export const savePans = vi.fn(ok);
|
export const savePans = vi.fn(ok);
|
||||||
export const listBolts = vi.fn(ok);
|
export const listBolts = vi.fn(ok);
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import type {
|
|||||||
PanInfo,
|
PanInfo,
|
||||||
PanInput,
|
PanInput,
|
||||||
Product,
|
Product,
|
||||||
|
ProductBatchInfo,
|
||||||
|
ProductBatchInput,
|
||||||
ProductionPlanInfo,
|
ProductionPlanInfo,
|
||||||
ProductSummaryItem,
|
ProductSummaryItem,
|
||||||
ProductTree,
|
ProductTree,
|
||||||
@ -36,6 +38,7 @@ import type {
|
|||||||
Supplier,
|
Supplier,
|
||||||
SystemConfig,
|
SystemConfig,
|
||||||
User,
|
User,
|
||||||
|
YarnInfo,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────
|
||||||
@ -118,13 +121,13 @@ export const updateConfig = (key: string, value: string) =>
|
|||||||
|
|
||||||
// ── Products ──────────────────────────────────────
|
// ── Products ──────────────────────────────────────
|
||||||
|
|
||||||
export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string }) =>
|
export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string; colorNo?: string; productCode?: string }) =>
|
||||||
request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params });
|
||||||
|
|
||||||
export const getProduct = (id: string) =>
|
export const getProduct = (id: string) =>
|
||||||
request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' });
|
request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' });
|
||||||
|
|
||||||
export const createProduct = (data: Partial<Product> & { pans?: PanInput[] }) =>
|
export const createProduct = (data: Partial<Product> & { pans?: PanInput[]; initialBatch?: ProductBatchInput }) =>
|
||||||
request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data });
|
request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data });
|
||||||
|
|
||||||
export const updateProduct = (id: string, data: Partial<Product>) =>
|
export const updateProduct = (id: string, data: Partial<Product>) =>
|
||||||
@ -147,6 +150,30 @@ export const getColorDetail = (productName: string) =>
|
|||||||
export const getProductTree = (id: string) =>
|
export const getProductTree = (id: string) =>
|
||||||
request<ApiResponse<ProductTree>>(`/api/v1/inventory/products/${id}/tree`, { method: 'GET' });
|
request<ApiResponse<ProductTree>>(`/api/v1/inventory/products/${id}/tree`, { method: 'GET' });
|
||||||
|
|
||||||
|
export const listYarns = (params?: PaginationParams & { yarnName?: string; supplierId?: string; status?: number }) =>
|
||||||
|
request<ApiResponse<PaginatedData<YarnInfo>>>('/api/v1/inventory/yarns', { method: 'GET', params });
|
||||||
|
|
||||||
|
export const createYarn = (data: Partial<YarnInfo>) =>
|
||||||
|
request<ApiResponse<{ id: string }>>('/api/v1/inventory/yarns', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const updateYarn = (id: string, data: Partial<YarnInfo>) =>
|
||||||
|
request<ApiResponse>(`/api/v1/inventory/yarns/${id}`, { method: 'PUT', data });
|
||||||
|
|
||||||
|
export const deleteYarn = (id: string) =>
|
||||||
|
request<ApiResponse>(`/api/v1/inventory/yarns/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
export const listProductBatches = (productId: string) =>
|
||||||
|
request<ApiResponse<{ list: ProductBatchInfo[] }>>(`/api/v1/inventory/products/${productId}/batches`, { method: 'GET' });
|
||||||
|
|
||||||
|
export const createProductBatch = (productId: string, batch: ProductBatchInput) =>
|
||||||
|
request<ApiResponse<{ id: string }>>(`/api/v1/inventory/products/${productId}/batches`, { method: 'POST', data: { batch } });
|
||||||
|
|
||||||
|
export const updateProductBatch = (productId: string, batchId: string, batch: ProductBatchInput, status?: number) =>
|
||||||
|
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'PUT', data: status === undefined ? { batch } : { batch, status } });
|
||||||
|
|
||||||
|
export const deleteProductBatch = (productId: string, batchId: string) =>
|
||||||
|
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'DELETE' });
|
||||||
|
|
||||||
// ── Pan / Bolt CRUD ───────────────────────────────
|
// ── Pan / Bolt CRUD ───────────────────────────────
|
||||||
|
|
||||||
export const listPans = (productId: string) =>
|
export const listPans = (productId: string) =>
|
||||||
|
|||||||
@ -40,6 +40,8 @@ export interface Product {
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
spec?: string;
|
spec?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
colorNo?: string;
|
||||||
|
productCode?: string;
|
||||||
salesPrice: string;
|
salesPrice: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
@ -57,6 +59,8 @@ export interface BoltInfo {
|
|||||||
export interface PanInfo {
|
export interface PanInfo {
|
||||||
panId: string;
|
panId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
|
batchId?: string;
|
||||||
|
batchNo?: string;
|
||||||
name: string;
|
name: string;
|
||||||
position: string;
|
position: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
@ -69,6 +73,7 @@ export interface PanInput {
|
|||||||
name: string;
|
name: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
boltLengths?: string[];
|
boltLengths?: string[];
|
||||||
|
batchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductTree {
|
export interface ProductTree {
|
||||||
@ -94,6 +99,8 @@ export interface ColorDetailItem {
|
|||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
colorNo?: string;
|
||||||
|
productCode?: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
panCount: number;
|
panCount: number;
|
||||||
@ -101,6 +108,39 @@ export interface ColorDetailItem {
|
|||||||
totalLengthM: string;
|
totalLengthM: string;
|
||||||
locations: string;
|
locations: string;
|
||||||
salesPrice: string;
|
salesPrice: string;
|
||||||
|
batchCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YarnInfo {
|
||||||
|
yarnId: string;
|
||||||
|
yarnName: string;
|
||||||
|
color: string;
|
||||||
|
weightGM: string;
|
||||||
|
supplierId: string;
|
||||||
|
supplierName?: string;
|
||||||
|
dyeFactory?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
remark?: string;
|
||||||
|
status?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductBatchInput {
|
||||||
|
yarnRatio?: string;
|
||||||
|
warpWeightGM?: string;
|
||||||
|
weftWeightGM?: string;
|
||||||
|
productionProcess?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductBatchInfo extends ProductBatchInput {
|
||||||
|
batchId: string;
|
||||||
|
productId: string;
|
||||||
|
batchNo: string;
|
||||||
|
status?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pan / Bolt dimension view ─────────────────────
|
// ── Pan / Bolt dimension view ─────────────────────
|
||||||
@ -108,6 +148,8 @@ export interface ColorDetailItem {
|
|||||||
export interface PanListItem {
|
export interface PanListItem {
|
||||||
panId: string;
|
panId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
|
batchId?: string;
|
||||||
|
batchNo?: string;
|
||||||
name: string;
|
name: string;
|
||||||
position: string;
|
position: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user