feat: improve product yarn ratio workflow

This commit is contained in:
kae 2026-07-05 07:33:09 +09:00
parent 6e3ee00a6a
commit cf94cb3b88
2 changed files with 436 additions and 25 deletions

View File

@ -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<typeof userEvent.setup>, 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(<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 () => {
render(<Page />);
await act(async () => {

View File

@ -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<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 summaryRef = useRef<ActionType>();
const [createForm] = Form.useForm();
const [viewLevel, setViewLevel] = useState<ViewLevel>('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<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) => {
@ -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 (
<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) => (
<a onClick={() => removeYarnRatioRow(side, row.key)}></a>
),
},
]}
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 ──
const summaryColumns: ProColumns<ProductSummaryItem>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
@ -220,7 +502,7 @@ const ProductsPage: React.FC = () => {
}}
toolBarRender={() => [
<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={() => setCreateOpen(true)}></Button>,
<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="export" icon={<DownloadOutlined />} onClick={handleExport}>Excel导出</Button>,
]}
@ -246,16 +528,19 @@ const ProductsPage: React.FC = () => {
<ModalForm
title="新增产品"
open={createOpen}
onOpenChange={setCreateOpen}
form={createForm}
onOpenChange={handleCreateOpenChange}
onFinish={async (values) => {
const selectedYarnIds = values.selectedYarnIds || [];
const ratioErrors = getRatioErrors();
if (ratioErrors.length > 0) {
const errorText = ratioErrors.join('');
setRatioSubmitError(errorText);
message.error(errorText);
return false;
}
const initialBatch = values.initialBatch || {};
const yarnRatio = initialBatch.yarnRatio || (selectedYarnIds.length ? JSON.stringify({
warp: selectedYarnIds.map((yarnId: string) => ({ yarn_id: yarnId, ratio: 1 })),
weft: [],
}) : '');
const yarnRatio = serializeYarnRatio();
const productValues = { ...values };
delete productValues.selectedYarnIds;
const res = await createProduct({
...productValues,
salesPrice: String(values.salesPrice || '0'),
@ -271,21 +556,25 @@ const ProductsPage: React.FC = () => {
<ProFormText name="color" label="颜色" initialValue="本色" />
<ProFormText name="colorNo" label="色号" />
<ProFormText name="salesPrice" label="销售价" />
<ProFormSelect
name="selectedYarnIds"
label="纱线"
request={async ({ keyWords }) => {
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' }}
/>
<Button type="dashed" size="small" onClick={() => setYarnOpen(true)} style={{ marginBottom: 12 }}>线</Button>
<ProFormTextArea name={['initialBatch', 'yarnRatio']} 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)" />
<ProFormText name={['initialBatch', 'weftWeightGM']} label="纬线克重(g/m)" />
<ProFormTextArea name={['initialBatch', 'productionProcess']} label="生产工艺" initialValue="无" />
<ProFormTextArea name="remark" label="备注" />
<ProFormText name={['initialBatch', 'productionProcess']} label="生产工艺" initialValue="无" />
<ProFormText name="remark" label="备注" />
</ModalForm>
{/* Edit product modal */}
@ -347,10 +636,34 @@ const ProductsPage: React.FC = () => {
<ModalForm
title="新增纱线"
open={yarnOpen}
onOpenChange={setYarnOpen}
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;
}