Compare commits
No commits in common. "baf3d4fc2d449058a6ceefbd951a9d58d43be47d" and "6e3ee00a6a3de1ea3334a06ebca8be1412997cad" have entirely different histories.
baf3d4fc2d
...
6e3ee00a6a
@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.superpowers
|
||||
__mocks__
|
||||
test
|
||||
*.md
|
||||
.umirc.local.ts
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,9 +2,6 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node,macos,visualstudiocode
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,visualstudiocode
|
||||
|
||||
# Allow src/pages Logs (overrides 'logs' ignore rule below)
|
||||
!src/pages/**/Logs/
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
variables:
|
||||
HARBOR_REGISTRY: harbor-in-k3s.cheverjohn.me
|
||||
HARBOR_PROJECT: iloom
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
DOCKER_HOST: tcp://localhost:2376
|
||||
DOCKER_TLS_VERIFY: "1"
|
||||
DOCKER_CERT_PATH: /certs/client
|
||||
|
||||
stages:
|
||||
- build
|
||||
|
||||
.build-template:
|
||||
stage: build
|
||||
image: docker:27
|
||||
services:
|
||||
- name: docker:27-dind
|
||||
command: ["--dns", "10.43.0.10", "--dns", "223.5.5.5"]
|
||||
retry: 2
|
||||
before_script:
|
||||
- until docker info >/dev/null 2>&1; do echo "Waiting for Docker daemon..."; sleep 2; done
|
||||
- echo "${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "${HARBOR_USER}" --password-stdin
|
||||
script:
|
||||
- |
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
TAG="$CI_COMMIT_TAG"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
TAG="main-${CI_COMMIT_SHORT_SHA}"
|
||||
else
|
||||
TAG="feat-${CI_COMMIT_SHORT_SHA}"
|
||||
fi
|
||||
- IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}:${TAG}
|
||||
- docker build --network host
|
||||
--build-arg BASE_REGISTRY=${HARBOR_REGISTRY}/library
|
||||
-f Dockerfile
|
||||
-t ${IMAGE} .
|
||||
- docker push ${IMAGE}
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
- if: $CI_COMMIT_TAG =~ /^v/
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH =~ /^feat\//
|
||||
|
||||
build-portal:
|
||||
extends: .build-template
|
||||
variables:
|
||||
IMAGE_NAME: muyu-portal
|
||||
18
.umirc.ts
18
.umirc.ts
@ -1,7 +1,6 @@
|
||||
import { defineConfig } from '@umijs/max';
|
||||
|
||||
export default defineConfig({
|
||||
hash: true,
|
||||
antd: {},
|
||||
access: {},
|
||||
model: {},
|
||||
@ -84,23 +83,6 @@ export default defineConfig({
|
||||
{ name: '采购报表', path: '/purchase/stats', component: './Purchase/Stats' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '生产管理',
|
||||
path: '/production',
|
||||
icon: 'ExperimentOutlined',
|
||||
routes: [
|
||||
{ name: '生产计划', path: '/production/plans', component: './Production/Plans/index' },
|
||||
{ name: '新建计划', path: '/production/plans/new', component: './Production/Plans/new', hideInMenu: true },
|
||||
{ name: '计划详情', path: '/production/plans/:planId', component: './Production/Plans/detail', hideInMenu: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '分享链接',
|
||||
path: '/share/production/:shortCode',
|
||||
component: './Production/Share/index',
|
||||
hideInMenu: true,
|
||||
layout: false,
|
||||
},
|
||||
{
|
||||
name: '系统管理',
|
||||
path: '/system',
|
||||
|
||||
24
Dockerfile
24
Dockerfile
@ -1,24 +0,0 @@
|
||||
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library
|
||||
FROM ${BASE_REGISTRY}/node:20-alpine AS builder
|
||||
ARG APK_MIRROR=mirrors.aliyun.com
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
|
||||
RUN [ -n "$APK_MIRROR" ] && sed -i "s|dl-cdn.alpinelinux.org|${APK_MIRROR}|g" /etc/apk/repositories || true
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --registry=${NPM_REGISTRY}
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM ${BASE_REGISTRY}/nginx:1.27-alpine
|
||||
ARG APK_MIRROR=mirrors.aliyun.com
|
||||
RUN [ -n "$APK_MIRROR" ] && sed -i "s|dl-cdn.alpinelinux.org|${APK_MIRROR}|g" /etc/apk/repositories || true
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
31
nginx.conf
31
nginx.conf
@ -1,31 +0,0 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# muyu-gateway API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://muyu-gateway:8888;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { Tag } from 'antd';
|
||||
|
||||
interface InventoryLog {
|
||||
id: number;
|
||||
productName: string;
|
||||
changeType: string;
|
||||
quantity: number;
|
||||
remark: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const columns: ProColumns<InventoryLog>[] = [
|
||||
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
||||
{
|
||||
title: '变动类型',
|
||||
dataIndex: 'changeType',
|
||||
valueEnum: {
|
||||
in: { text: '入库', status: 'Success' },
|
||||
out: { text: '出库', status: 'Error' },
|
||||
adjust: { text: '调整', status: 'Warning' },
|
||||
},
|
||||
},
|
||||
{ title: '数量', dataIndex: 'quantity', search: false },
|
||||
{ title: '备注', dataIndex: 'remark', search: false, ellipsis: true },
|
||||
{ title: '变动时间', dataIndex: 'createdAt', search: false, valueType: 'dateTime' },
|
||||
];
|
||||
|
||||
const LogsPage: React.FC = () => (
|
||||
<ProTable<InventoryLog>
|
||||
headerTitle="库存变动记录"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
request={async () => ({
|
||||
data: [],
|
||||
total: 0,
|
||||
success: true,
|
||||
})}
|
||||
search={{ labelWidth: 'auto' }}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
);
|
||||
|
||||
export default LogsPage;
|
||||
@ -7,7 +7,7 @@ vi.mock('@/services/api');
|
||||
|
||||
import { smokeTest } from '../../../../test/smoke';
|
||||
import Page from './index';
|
||||
import { createProduct, getProductSummary, listYarns } from '@/services/api';
|
||||
import { createProduct, getProductSummary } 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,104 +68,6 @@ 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 () => {
|
||||
|
||||
@ -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 { Alert, Button, Card, Descriptions, Drawer, Form, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Table, Tag } from 'antd';
|
||||
import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Tag } from 'antd';
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
getProductSummary,
|
||||
@ -19,37 +19,12 @@ import {
|
||||
updateProductBatch,
|
||||
deleteProductBatch,
|
||||
} from '@/services/api';
|
||||
import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo, YarnInfo } from '@/types/api';
|
||||
import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo } 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('');
|
||||
@ -63,12 +38,6 @@ 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) => {
|
||||
@ -104,257 +73,6 @@ 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) => (
|
||||
<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 ──
|
||||
const summaryColumns: ProColumns<ProductSummaryItem>[] = [
|
||||
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
||||
@ -502,7 +220,7 @@ render: (_, row) => (
|
||||
}}
|
||||
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={() => handleCreateOpenChange(true)}>新增产品</Button>,
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新增产品</Button>,
|
||||
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>,
|
||||
<Button key="export" icon={<DownloadOutlined />} onClick={handleExport}>Excel导出</Button>,
|
||||
]}
|
||||
@ -528,19 +246,16 @@ render: (_, row) => (
|
||||
<ModalForm
|
||||
title="新增产品"
|
||||
open={createOpen}
|
||||
form={createForm}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
onOpenChange={setCreateOpen}
|
||||
onFinish={async (values) => {
|
||||
const ratioErrors = getRatioErrors();
|
||||
if (ratioErrors.length > 0) {
|
||||
const errorText = ratioErrors.join(';');
|
||||
setRatioSubmitError(errorText);
|
||||
message.error(errorText);
|
||||
return false;
|
||||
}
|
||||
const selectedYarnIds = values.selectedYarnIds || [];
|
||||
const initialBatch = values.initialBatch || {};
|
||||
const yarnRatio = serializeYarnRatio();
|
||||
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'),
|
||||
@ -556,25 +271,21 @@ render: (_, row) => (
|
||||
<ProFormText name="color" label="颜色" initialValue="本色" />
|
||||
<ProFormText name="colorNo" label="色号" />
|
||||
<ProFormText name="salesPrice" 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="备注" />
|
||||
<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="纱线配比" />
|
||||
<ProFormText name={['initialBatch', 'warpWeightGM']} label="经线克重(g/m)" />
|
||||
<ProFormText name={['initialBatch', 'weftWeightGM']} label="纬线克重(g/m)" />
|
||||
<ProFormTextArea name={['initialBatch', 'productionProcess']} label="生产工艺" initialValue="无" />
|
||||
<ProFormTextArea name="remark" label="备注" />
|
||||
</ModalForm>
|
||||
|
||||
{/* Edit product modal */}
|
||||
@ -636,34 +347,10 @@ render: (_, row) => (
|
||||
<ModalForm
|
||||
title="新增纱线"
|
||||
open={yarnOpen}
|
||||
onOpenChange={(open) => {
|
||||
setYarnOpen(open);
|
||||
if (!open) setYarnCreateTarget(null);
|
||||
}}
|
||||
onOpenChange={setYarnOpen}
|
||||
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;
|
||||
}
|
||||
@ -673,7 +360,7 @@ render: (_, row) => (
|
||||
>
|
||||
<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: '请输入数字' }]} />
|
||||
<ProFormText name="weightGM" label="纱线克重(g/m)" rules={[{ required: true }]} />
|
||||
<ProFormSelect
|
||||
name="supplierId"
|
||||
label="供应商"
|
||||
@ -736,18 +423,17 @@ render: (_, row) => (
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Select
|
||||
<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 };
|
||||
next[pi] = { ...next[pi], batchId: value };
|
||||
setPanInputs(next);
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</Space>
|
||||
{pan.boltLengths?.map((len, bi) => (
|
||||
<Space key={bi} style={{ display: 'flex', marginBottom: 4 }}>
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { describe, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@umijs/max', () => ({
|
||||
request: vi.fn(() => Promise.resolve({ code: 0, msg: 'ok', success: true, data: { list: [], total: 0 } })),
|
||||
history: { push: vi.fn(), replace: vi.fn(), back: vi.fn(), go: vi.fn(), location: { pathname: '/' } },
|
||||
useModel: vi.fn(() => ({ initialState: { currentUser: { name: 'tester', menuPaths: [] } }, setInitialState: vi.fn() })),
|
||||
useParams: vi.fn(() => ({ planId: 'plan-1' })),
|
||||
useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
|
||||
useNavigate: vi.fn(() => vi.fn()),
|
||||
useLocation: vi.fn(() => ({ pathname: '/' })),
|
||||
useAccess: vi.fn(() => ({})),
|
||||
Link: ({ children }: any) => children,
|
||||
NavLink: ({ children }: any) => children,
|
||||
Access: ({ children }: any) => children,
|
||||
Outlet: () => null,
|
||||
}));
|
||||
vi.mock('@/services/api');
|
||||
|
||||
import { smokeTest } from '../../../../test/smoke';
|
||||
import Page from './detail';
|
||||
|
||||
describe('Production / Plans / detail page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
smokeTest(Page);
|
||||
});
|
||||
@ -1,331 +0,0 @@
|
||||
import { ArrowLeftOutlined, CopyOutlined, PlusOutlined, ShareAltOutlined } from '@ant-design/icons';
|
||||
import { ProTable } from '@ant-design/pro-components';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Steps,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { history, useParams } from '@umijs/max';
|
||||
import {
|
||||
completeStep,
|
||||
confirmPlan,
|
||||
createInbound,
|
||||
createShareLink,
|
||||
getProductionPlan,
|
||||
listInbound,
|
||||
} from '@/services/api';
|
||||
import type { InboundRecordInfo, ProductionPlanInfo, ShareLinkInfo } from '@/types/api';
|
||||
|
||||
const STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '草稿', color: 'default' },
|
||||
1: { text: '待生产', color: 'processing' },
|
||||
2: { text: '生产中', color: 'warning' },
|
||||
3: { text: '已完成', color: 'success' },
|
||||
4: { text: '已取消', color: 'error' },
|
||||
};
|
||||
|
||||
const STEP_TYPES = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
confirm: '计划确认',
|
||||
yarn_purchase: '纱线采购',
|
||||
dyeing: '染色',
|
||||
machine_start: '上机生产',
|
||||
fabric_warehouse: '成品入库',
|
||||
};
|
||||
|
||||
const STEP_STATUS_MAP: Record<number, 'wait' | 'process' | 'finish' | 'error'> = {
|
||||
0: 'wait',
|
||||
1: 'process',
|
||||
2: 'finish',
|
||||
};
|
||||
|
||||
const PlanDetailPage: React.FC = () => {
|
||||
const { planId } = useParams<{ planId: string }>();
|
||||
const [plan, setPlan] = useState<ProductionPlanInfo | null>(null);
|
||||
const [inboundRecords, setInboundRecords] = useState<InboundRecordInfo[]>([]);
|
||||
const [inboundTotal, setInboundTotal] = useState(0);
|
||||
const [inboundModalOpen, setInboundModalOpen] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<ShareLinkInfo | null>(null);
|
||||
const [inboundForm] = Form.useForm();
|
||||
|
||||
const loadPlan = async () => {
|
||||
if (!planId) return;
|
||||
const res = await getProductionPlan(planId);
|
||||
if (res?.code === 0) setPlan(res.data);
|
||||
};
|
||||
|
||||
const loadInbound = async () => {
|
||||
if (!planId) return;
|
||||
const res = await listInbound({ planId, page: 1, pageSize: 100 });
|
||||
if (res?.code === 0) {
|
||||
setInboundRecords(res.data?.list || []);
|
||||
setInboundTotal(res.data?.total || 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPlan();
|
||||
loadInbound();
|
||||
}, [planId]);
|
||||
|
||||
const handleConfirmPlan = async () => {
|
||||
if (!planId) return;
|
||||
await confirmPlan({ planId });
|
||||
message.success('计划已确认');
|
||||
loadPlan();
|
||||
};
|
||||
|
||||
const handleCompleteStep = async (stepType: string) => {
|
||||
if (!planId) return;
|
||||
await completeStep({ planId, stepType });
|
||||
message.success('工序已完成');
|
||||
loadPlan();
|
||||
};
|
||||
|
||||
const handleCreateInbound = async () => {
|
||||
if (!planId) return;
|
||||
const values = await inboundForm.validateFields();
|
||||
await createInbound({ planId, ...values });
|
||||
message.success('入库记录已创建');
|
||||
setInboundModalOpen(false);
|
||||
inboundForm.resetFields();
|
||||
loadPlan();
|
||||
loadInbound();
|
||||
};
|
||||
|
||||
const handleCreateShareLink = async () => {
|
||||
if (!planId) return;
|
||||
const res = await createShareLink({ planId, expiresHours: 72 });
|
||||
if (res?.code === 0 && res.data) {
|
||||
setShareLink(res.data);
|
||||
message.success('分享链接已生成');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (!shareLink) return;
|
||||
const url = `${window.location.origin}/share/production/${shareLink.shortCode}`;
|
||||
navigator.clipboard.writeText(url).then(() => message.success('链接已复制'));
|
||||
};
|
||||
|
||||
if (!plan) return null;
|
||||
|
||||
const statusInfo = STATUS_MAP[plan.status] ?? STATUS_MAP[0];
|
||||
|
||||
// Build step items from STEP_TYPES with plan's processSteps data
|
||||
const stepsByType = Object.fromEntries((plan.processSteps || []).map((s) => [s.stepType, s]));
|
||||
const stepsItems = STEP_TYPES.map((type) => {
|
||||
const step = stepsByType[type];
|
||||
return {
|
||||
title: STEP_LABELS[type],
|
||||
status: step ? STEP_STATUS_MAP[step.status] ?? 'wait' : 'wait',
|
||||
description: step?.completedAt || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => history.back()}>返回</Button>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
生产计划详情 — {plan.planCode}
|
||||
</Typography.Title>
|
||||
<Tag color={statusInfo.color}>{statusInfo.text}</Tag>
|
||||
</Space>
|
||||
|
||||
{plan.status === 0 && (
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => Modal.confirm({ title: '确认此生产计划?', onOk: handleConfirmPlan })}>
|
||||
确认计划
|
||||
</Button>
|
||||
<Button icon={<ShareAltOutlined />} onClick={handleCreateShareLink}>分享给纺织厂</Button>
|
||||
</Space>
|
||||
)}
|
||||
{plan.status === 1 && (
|
||||
<Button icon={<ShareAltOutlined />} onClick={handleCreateShareLink}>分享给纺织厂</Button>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="info"
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Card>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
<Descriptions.Item label="计划编号">{plan.planCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="产品名称">{plan.productName}</Descriptions.Item>
|
||||
<Descriptions.Item label="颜色">{plan.color}</Descriptions.Item>
|
||||
<Descriptions.Item label="面料编号">{plan.fabricCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="色号">{plan.colorCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标数量">{plan.targetQuantity} 米</Descriptions.Item>
|
||||
<Descriptions.Item label="已完成">{plan.completedQuantity} 米</Descriptions.Item>
|
||||
<Descriptions.Item label="每米用纱量">{plan.yarnUsagePerMeter}</Descriptions.Item>
|
||||
<Descriptions.Item label="生产单价">¥ {plan.productionPrice}</Descriptions.Item>
|
||||
<Descriptions.Item label="开工日期">{plan.startTime}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人">{plan.creator}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{plan.createdAt}</Descriptions.Item>
|
||||
{plan.remark && <Descriptions.Item label="备注" span={3}>{plan.remark}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{plan.yarnRatios && plan.yarnRatios.length > 0 && (
|
||||
<Card title="纱线配比">
|
||||
<Table
|
||||
rowKey="ratioId"
|
||||
dataSource={plan.yarnRatios}
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '纱线名称', dataIndex: 'yarnName' },
|
||||
{ title: '配比 (%)', dataIndex: 'ratio' },
|
||||
{ title: '每米用量', dataIndex: 'amountPerMeter' },
|
||||
{ title: '总用量', dataIndex: 'totalAmount' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'process',
|
||||
label: '生产流程',
|
||||
children: (
|
||||
<Card>
|
||||
<Steps current={-1} items={stepsItems} />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
{STEP_TYPES.map((type) => {
|
||||
const step = stepsByType[type];
|
||||
const isCompleted = step?.status === 2;
|
||||
const canComplete = plan.status >= 1 && !isCompleted;
|
||||
return canComplete ? (
|
||||
<Button
|
||||
key={type}
|
||||
style={{ marginRight: 8, marginBottom: 8 }}
|
||||
onClick={() => Modal.confirm({ title: `完成「${STEP_LABELS[type]}」工序?`, onOk: () => handleCompleteStep(type) })}
|
||||
>
|
||||
完成: {STEP_LABELS[type]}
|
||||
</Button>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'inbound',
|
||||
label: '入库记录',
|
||||
children: (
|
||||
<Card
|
||||
title={`入库记录 (共 ${inboundTotal} 条)`}
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={() => { inboundForm.resetFields(); setInboundModalOpen(true); }}>新增入库</Button>
|
||||
}
|
||||
>
|
||||
<ProTable<InboundRecordInfo>
|
||||
headerTitle={false}
|
||||
rowKey="recordId"
|
||||
search={false}
|
||||
pagination={false}
|
||||
dataSource={inboundRecords}
|
||||
columns={[
|
||||
{ title: '批次号', dataIndex: 'batchNo' },
|
||||
{ title: '数量(米)', dataIndex: 'quantity' },
|
||||
{ title: '匹数', dataIndex: 'rolls' },
|
||||
{ title: '单价', dataIndex: 'pricePerMeter', render: (v) => `¥ ${v}` },
|
||||
{ title: '备注', dataIndex: 'remark' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
label: '分享管理',
|
||||
children: (
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button icon={<ShareAltOutlined />} onClick={handleCreateShareLink}>生成分享链接</Button>
|
||||
{shareLink && (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="短码">{shareLink.shortCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={
|
||||
shareLink.status === 0 ? 'processing' :
|
||||
shareLink.status === 1 ? 'cyan' :
|
||||
shareLink.status === 2 ? 'success' :
|
||||
shareLink.status === 3 ? 'error' :
|
||||
shareLink.status === 4 ? 'default' : 'default'
|
||||
}>
|
||||
{shareLink.status === 0 ? '待处理' :
|
||||
shareLink.status === 1 ? '已点击' :
|
||||
shareLink.status === 2 ? '已确认' :
|
||||
shareLink.status === 3 ? '已拒绝' :
|
||||
shareLink.status === 4 ? '已过期' : '未知'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="过期时间">{shareLink.expiresAt}</Descriptions.Item>
|
||||
<Descriptions.Item label="点击数">{shareLink.clickCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="链接" span={2}>
|
||||
<Space>
|
||||
<Typography.Text copyable>{`${window.location.origin}/share/production/${shareLink.shortCode}`}</Typography.Text>
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={handleCopyLink}>复制</Button>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal title="新增入库" open={inboundModalOpen} onOk={handleCreateInbound} onCancel={() => setInboundModalOpen(false)} destroyOnClose>
|
||||
<Form form={inboundForm} layout="vertical">
|
||||
<Form.Item name="batchNo" label="批次号" rules={[{ required: true, message: '请输入批次号' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="quantity" label="数量(米)" rules={[{ required: true, message: '请输入数量' }]}>
|
||||
<Input type="number" min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="rolls" label="匹数" rules={[{ required: true, message: '请输入匹数' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="pricePerMeter" label="单价(每米)" rules={[{ required: true, message: '请输入单价' }]}>
|
||||
<Input type="number" min={0} step="0.01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanDetailPage;
|
||||
@ -1,15 +0,0 @@
|
||||
import { describe, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@umijs/max');
|
||||
vi.mock('@/services/api');
|
||||
|
||||
import { smokeTest } from '../../../../test/smoke';
|
||||
import Page from './index';
|
||||
|
||||
describe('Production / Plans / index page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
smokeTest(Page);
|
||||
});
|
||||
@ -1,85 +0,0 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { Button, Progress, Tag } from 'antd';
|
||||
import { useRef } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { listProductionPlan } from '@/services/api';
|
||||
import type { ProductionPlanInfo } from '@/types/api';
|
||||
|
||||
const STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '草稿', color: 'default' },
|
||||
1: { text: '待生产', color: 'processing' },
|
||||
2: { text: '生产中', color: 'warning' },
|
||||
3: { text: '已完成', color: 'success' },
|
||||
4: { text: '已取消', color: 'error' },
|
||||
};
|
||||
|
||||
const PlansPage: React.FC = () => {
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
||||
const columns: ProColumns<ProductionPlanInfo>[] = [
|
||||
{ title: '计划编号', dataIndex: 'planCode', copyable: true },
|
||||
{ title: '产品名称', dataIndex: 'productName' },
|
||||
{ title: '颜色', dataIndex: 'color', search: false },
|
||||
{ title: '目标数量', dataIndex: 'targetQuantity', search: false },
|
||||
{
|
||||
title: '完成进度',
|
||||
dataIndex: 'completedQuantity',
|
||||
search: false,
|
||||
render: (_, r) => {
|
||||
const target = parseFloat(r.targetQuantity) || 1;
|
||||
const completed = parseFloat(r.completedQuantity) || 0;
|
||||
const pct = Math.min(Math.round((completed / target) * 100), 100);
|
||||
return <Progress percent={pct} size="small" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
Object.entries(STATUS_MAP).map(([k, v]) => [k, { text: v.text }]),
|
||||
),
|
||||
render: (_, r) => {
|
||||
const s = STATUS_MAP[r.status] ?? STATUS_MAP[0];
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, r) => [
|
||||
<a key="view" onClick={() => history.push(`/production/plans/${r.planId}`)}>详情</a>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProTable<ProductionPlanInfo>
|
||||
actionRef={actionRef}
|
||||
headerTitle="生产计划"
|
||||
rowKey="planId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
const res = await listProductionPlan({
|
||||
page: params.current ?? 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
status: params.status != null ? Number(params.status) : undefined,
|
||||
productName: params.productName,
|
||||
planCode: params.planCode,
|
||||
});
|
||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => history.push('/production/plans/new')}>新建计划</Button>,
|
||||
]}
|
||||
onRow={(record) => ({
|
||||
onClick: () => history.push(`/production/plans/${record.planId}`),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlansPage;
|
||||
@ -1,15 +0,0 @@
|
||||
import { describe, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@umijs/max');
|
||||
vi.mock('@/services/api');
|
||||
|
||||
import { smokeTest } from '../../../../test/smoke';
|
||||
import Page from './new';
|
||||
|
||||
describe('Production / Plans / new page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
smokeTest(Page);
|
||||
});
|
||||
@ -1,208 +0,0 @@
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Col, DatePicker, Form, Input, InputNumber, Row, Select, Space, Table, Typography, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { createProductionPlan, listProducts, listSuppliers } from '@/services/api';
|
||||
import type { Product, Supplier } from '@/types/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface YarnRow {
|
||||
key: number;
|
||||
yarnName?: string;
|
||||
ratio?: string;
|
||||
amountPerMeter?: string;
|
||||
}
|
||||
|
||||
let rowKey = 0;
|
||||
|
||||
const NewPlanPage: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [yarnRows, setYarnRows] = useState<YarnRow[]>([{ key: rowKey++ }]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listSuppliers({ page: 1, pageSize: 200, status: 1 }).then((res) => {
|
||||
if (res?.code === 0) setSuppliers(res.data?.list || []);
|
||||
});
|
||||
listProducts({ page: 1, pageSize: 500 }).then((res) => {
|
||||
if (res?.code === 0) setProducts(res.data?.list || []);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const productMap = Object.fromEntries(products.map((p) => [p.productId, p]));
|
||||
|
||||
const handleProductChange = (productId: string) => {
|
||||
const p = productMap[productId];
|
||||
if (!p) return;
|
||||
form.setFieldsValue({ productName: p.productName, color: p.color || '' });
|
||||
};
|
||||
|
||||
const handleYarnField = (idx: number, field: keyof YarnRow, value: string) => {
|
||||
setYarnRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r)));
|
||||
};
|
||||
|
||||
const addYarnRow = () => setYarnRows((prev) => [...prev, { key: rowKey++ }]);
|
||||
const removeYarnRow = (idx: number) => setYarnRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let values: any;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const yarnRatios = yarnRows
|
||||
.filter((r) => r.yarnName && r.ratio)
|
||||
.map((r) => ({ yarnName: r.yarnName!, ratio: r.ratio!, amountPerMeter: r.amountPerMeter || '0' }));
|
||||
|
||||
const res = await createProductionPlan({
|
||||
...values,
|
||||
startTime: values.startTime?.format?.('YYYY-MM-DD') || values.startTime,
|
||||
yarnRatios,
|
||||
});
|
||||
if (res?.code !== 0) {
|
||||
message.error(res?.msg || '创建失败');
|
||||
return;
|
||||
}
|
||||
message.success('生产计划已创建');
|
||||
history.push('/production/plans');
|
||||
} catch {
|
||||
message.error('创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const yarnColumns = [
|
||||
{
|
||||
title: '纱线名称',
|
||||
width: 180,
|
||||
render: (_: unknown, _row: YarnRow, idx: number) => (
|
||||
<Input value={yarnRows[idx].yarnName} onChange={(e) => handleYarnField(idx, 'yarnName', e.target.value)} placeholder="纱线名称" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '配比 (%)',
|
||||
width: 120,
|
||||
render: (_: unknown, _row: YarnRow, idx: number) => (
|
||||
<Input value={yarnRows[idx].ratio} onChange={(e) => handleYarnField(idx, 'ratio', e.target.value)} placeholder="如 30" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '每米用量',
|
||||
width: 120,
|
||||
render: (_: unknown, _row: YarnRow, idx: number) => (
|
||||
<Input value={yarnRows[idx].amountPerMeter} onChange={(e) => handleYarnField(idx, 'amountPerMeter', e.target.value)} placeholder="kg/m" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 48,
|
||||
render: (_: unknown, _row: YarnRow, idx: number) => (
|
||||
<Button type="text" danger icon={<DeleteOutlined />} disabled={yarnRows.length === 1} onClick={() => removeYarnRow(idx)} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Space>
|
||||
<Button onClick={() => history.back()}>返回</Button>
|
||||
<Title level={4} style={{ margin: 0 }}>新建生产计划</Title>
|
||||
</Space>
|
||||
|
||||
<Card>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="productId" label="产品" rules={[{ required: true, message: '请选择产品' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="选择产品"
|
||||
optionFilterProp="label"
|
||||
options={products.map((p) => ({ value: p.productId, label: `${p.productName}${p.color ? ` ${p.color}` : ''}` }))}
|
||||
onChange={handleProductChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="productName" label="产品名称">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="color" label="颜色">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={24}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="fabricCode" label="面料编号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="colorCode" label="色号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="targetQuantity" label="目标数量(米)" rules={[{ required: true, message: '请输入目标数量' }]}>
|
||||
<Input type="number" min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="yarnUsagePerMeter" label="每米用纱量">
|
||||
<Input type="number" min={0} step="0.01" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="productionPrice" label="生产单价">
|
||||
<Input type="number" min={0} step="0.01" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="supplierId" label="供应商(纺织厂)">
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="选择供应商"
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
options={suppliers.map((s) => ({ value: s.supplierId, label: s.supplierName }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="startTime" label="开工日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="纱线配比" extra={<Button icon={<PlusOutlined />} onClick={addYarnRow}>添加纱线</Button>}>
|
||||
<Table rowKey="key" dataSource={yarnRows} columns={yarnColumns} pagination={false} size="small" />
|
||||
</Card>
|
||||
|
||||
<Space>
|
||||
<Button onClick={() => history.back()}>取消</Button>
|
||||
<Button type="primary" loading={submitting} onClick={handleSubmit}>提交</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewPlanPage;
|
||||
@ -1,28 +0,0 @@
|
||||
import { describe, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@umijs/max', () => ({
|
||||
request: vi.fn(() => Promise.resolve({ code: 0, msg: 'ok', success: true, data: { list: [], total: 0 } })),
|
||||
history: { push: vi.fn(), replace: vi.fn(), back: vi.fn(), go: vi.fn(), location: { pathname: '/' } },
|
||||
useModel: vi.fn(() => ({ initialState: { currentUser: { name: 'tester', menuPaths: [] } }, setInitialState: vi.fn() })),
|
||||
useParams: vi.fn(() => ({ shortCode: 'abc123' })),
|
||||
useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
|
||||
useNavigate: vi.fn(() => vi.fn()),
|
||||
useLocation: vi.fn(() => ({ pathname: '/' })),
|
||||
useAccess: vi.fn(() => ({})),
|
||||
Link: ({ children }: any) => children,
|
||||
NavLink: ({ children }: any) => children,
|
||||
Access: ({ children }: any) => children,
|
||||
Outlet: () => null,
|
||||
}));
|
||||
vi.mock('@/services/api');
|
||||
|
||||
import { smokeTest } from '../../../../test/smoke';
|
||||
import Page from './index';
|
||||
|
||||
describe('Production / Share / index page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
smokeTest(Page);
|
||||
});
|
||||
@ -1,174 +0,0 @@
|
||||
import { Button, Card, Descriptions, Input, Result, Space, Spin, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from '@umijs/max';
|
||||
import {
|
||||
clickShareLink,
|
||||
confirmShareLink,
|
||||
getShareLinkPlanView,
|
||||
rejectShareLink,
|
||||
} from '@/services/api';
|
||||
import type { ShareLinkPlanView } from '@/types/api';
|
||||
|
||||
const PLAN_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '草稿', color: 'default' },
|
||||
1: { text: '待生产', color: 'processing' },
|
||||
2: { text: '生产中', color: 'warning' },
|
||||
3: { text: '已完成', color: 'success' },
|
||||
4: { text: '已取消', color: 'error' },
|
||||
};
|
||||
|
||||
const LINK_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待处理', color: 'processing' },
|
||||
1: { text: '已点击', color: 'cyan' },
|
||||
2: { text: '已确认', color: 'success' },
|
||||
3: { text: '已拒绝', color: 'error' },
|
||||
4: { text: '已过期', color: 'default' },
|
||||
};
|
||||
|
||||
const SharePage: React.FC = () => {
|
||||
const { shortCode } = useParams<{ shortCode: string }>();
|
||||
const [data, setData] = useState<ShareLinkPlanView | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [showReject, setShowReject] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!shortCode) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// Record click
|
||||
await clickShareLink({ shortCode }).catch(() => {});
|
||||
const res = await getShareLinkPlanView(shortCode);
|
||||
if (res?.code === 0) {
|
||||
setData(res.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [shortCode]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!shortCode) return;
|
||||
try {
|
||||
await confirmShareLink({ shortCode });
|
||||
message.success('已确认接单');
|
||||
load();
|
||||
} catch {
|
||||
message.error('操作失败,请登录后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!shortCode || !rejectReason.trim()) {
|
||||
message.warning('请填写拒绝原因');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await rejectShareLink({ shortCode, rejectReason: rejectReason.trim() });
|
||||
message.success('已拒绝');
|
||||
setShowReject(false);
|
||||
load();
|
||||
} catch {
|
||||
message.error('操作失败,请登录后重试');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Result status="404" title="链接无效" subTitle="该分享链接不存在或已过期" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { plan, link } = data;
|
||||
const hideSensitive = link.hideSensitive === 1;
|
||||
const planStatus = PLAN_STATUS_MAP[plan.status] ?? PLAN_STATUS_MAP[0];
|
||||
const linkStatus = LINK_STATUS_MAP[link.status] ?? LINK_STATUS_MAP[0];
|
||||
const isPending = link.status === 0 || link.status === 1;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '40px auto', padding: '0 16px' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Typography.Title level={3}>生产计划 — {plan.planCode}</Typography.Title>
|
||||
|
||||
<Card>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="计划编号">{plan.planCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="产品名称">{plan.productName}</Descriptions.Item>
|
||||
<Descriptions.Item label="颜色">{plan.color}</Descriptions.Item>
|
||||
<Descriptions.Item label="面料编号">{plan.fabricCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标数量">{plan.targetQuantity} 米</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={planStatus.color}>{planStatus.text}</Tag>
|
||||
</Descriptions.Item>
|
||||
{!hideSensitive && (
|
||||
<>
|
||||
<Descriptions.Item label="生产单价">¥ {plan.productionPrice}</Descriptions.Item>
|
||||
<Descriptions.Item label="每米用纱量">{plan.yarnUsagePerMeter}</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
<Descriptions.Item label="开工日期">{plan.startTime}</Descriptions.Item>
|
||||
{plan.remark && <Descriptions.Item label="备注" span={2}>{plan.remark}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{plan.yarnRatios && plan.yarnRatios.length > 0 && (
|
||||
<Card title="纱线配比">
|
||||
<Descriptions column={1} size="small">
|
||||
{plan.yarnRatios.map((yr) => (
|
||||
<Descriptions.Item key={yr.ratioId} label={yr.yarnName}>
|
||||
配比 {yr.ratio}%{!hideSensitive ? ` — 每米用量 ${yr.amountPerMeter}` : ''}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Typography.Text>
|
||||
当前状态: <Tag color={linkStatus.color}>{linkStatus.text}</Tag>
|
||||
</Typography.Text>
|
||||
{link.rejectReason && (
|
||||
<Typography.Text type="danger">拒绝原因: {link.rejectReason}</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{isPending && (
|
||||
<Space>
|
||||
<Button type="primary" size="large" onClick={handleConfirm}>确认接单</Button>
|
||||
<Button danger size="large" onClick={() => setShowReject(!showReject)}>拒绝</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{showReject && (
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写拒绝原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
<Button danger onClick={handleReject}>确认拒绝</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SharePage;
|
||||
@ -111,50 +111,3 @@ export const getPurchaseStatsSummary = vi.fn(ok);
|
||||
export const getPurchaseStatsBySupplier = vi.fn(ok);
|
||||
export const getPurchaseStatsByProduct = vi.fn(ok);
|
||||
export const listInventoryLogs = vi.fn(ok);
|
||||
|
||||
// ── Production ───────────────────────────────────
|
||||
|
||||
export const createProductionPlan = vi.fn(ok);
|
||||
export const listProductionPlan = vi.fn(ok);
|
||||
export const getProductionPlan = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
code: 0, msg: 'ok', success: true,
|
||||
data: {
|
||||
planId: 'plan-1', planCode: 'PP-001', productName: '测试产品', color: '红色',
|
||||
fabricCode: 'FC-01', colorCode: 'CC-01', targetQuantity: '1000', completedQuantity: '0',
|
||||
yarnUsagePerMeter: '0.5', productionPrice: '10', supplierId: 's1', status: 0,
|
||||
remark: '', startTime: '2024-01-01', creator: 'admin', operator: 'admin',
|
||||
createdAt: '2024-01-01', updatedAt: '2024-01-01', tenantId: 't1',
|
||||
yarnRatios: [], processSteps: [], inboundRecords: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
export const confirmPlan = vi.fn(ok);
|
||||
export const createShareLink = vi.fn(ok);
|
||||
export const completeStep = vi.fn(ok);
|
||||
export const createInbound = vi.fn(ok);
|
||||
export const listInbound = vi.fn(ok);
|
||||
export const getShareLinkPlanView = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
code: 0, msg: 'ok', success: true,
|
||||
data: {
|
||||
plan: {
|
||||
planId: 'plan-1', planCode: 'PP-001', productName: '测试产品', color: '红色',
|
||||
fabricCode: 'FC-01', colorCode: 'CC-01', targetQuantity: '1000', completedQuantity: '0',
|
||||
yarnUsagePerMeter: '0.5', productionPrice: '10', supplierId: 's1', status: 1,
|
||||
remark: '', startTime: '2024-01-01', creator: 'admin', operator: 'admin',
|
||||
createdAt: '2024-01-01', updatedAt: '2024-01-01', tenantId: 't1',
|
||||
yarnRatios: [], processSteps: [], inboundRecords: [],
|
||||
},
|
||||
link: {
|
||||
linkId: 'link-1', tenantId: 't1', planId: 'plan-1', shortCode: 'abc123',
|
||||
token: 'tok', factoryType: 'weaving', hideSensitive: 0, status: 0,
|
||||
clickCount: 1, clickedAt: '', respondedAt: '', rejectReason: '',
|
||||
expiresAt: '2024-12-31', createdBy: 'admin', createdAt: '2024-01-01', updatedAt: '2024-01-01',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
export const clickShareLink = vi.fn(ok);
|
||||
export const confirmShareLink = vi.fn(ok);
|
||||
export const rejectShareLink = vi.fn(ok);
|
||||
|
||||
@ -4,7 +4,6 @@ import type {
|
||||
BoltInfo,
|
||||
ColorDetailItem,
|
||||
CurrentUser,
|
||||
InboundRecordInfo,
|
||||
InventoryLog,
|
||||
LoginParams,
|
||||
LoginResult,
|
||||
@ -17,7 +16,6 @@ import type {
|
||||
Product,
|
||||
ProductBatchInfo,
|
||||
ProductBatchInput,
|
||||
ProductionPlanInfo,
|
||||
ProductSummaryItem,
|
||||
ProductTree,
|
||||
PurchaseOrder,
|
||||
@ -29,8 +27,6 @@ import type {
|
||||
Relation,
|
||||
RelationHistory,
|
||||
Role,
|
||||
ShareLinkInfo,
|
||||
ShareLinkPlanView,
|
||||
StockAdjust,
|
||||
StockCheck,
|
||||
StockGroup,
|
||||
@ -168,8 +164,8 @@ export const listProductBatches = (productId: string) =>
|
||||
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 updateProductBatch = (productId: string, batchId: string, batch: ProductBatchInput & { status?: number }) =>
|
||||
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'PUT', data: { batch, status: batch.status ?? 1 } });
|
||||
|
||||
export const deleteProductBatch = (productId: string, batchId: string) =>
|
||||
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'DELETE' });
|
||||
@ -374,45 +370,3 @@ export const getPurchaseStatsByProduct = (params: { startDate?: string; endDate?
|
||||
|
||||
export const listInventoryLogs = (params: PaginationParams & { productId?: string; changeType?: number; startDate?: string; endDate?: string }) =>
|
||||
request<ApiResponse<PaginatedData<InventoryLog>>>('/api/v1/inventory/logs', { method: 'GET', params });
|
||||
|
||||
// ── Production Plans ─────────────────────────────
|
||||
|
||||
export const createProductionPlan = (data: any) =>
|
||||
request<ApiResponse<{ id: string }>>('/api/v1/production/plan', { method: 'POST', data });
|
||||
|
||||
export const listProductionPlan = (params: { page: number; pageSize: number; status?: number; productName?: string; planCode?: string }) =>
|
||||
request<ApiResponse<{ total: number; list: ProductionPlanInfo[] }>>('/api/v1/production/plan', { method: 'GET', params });
|
||||
|
||||
export const getProductionPlan = (planId: string) =>
|
||||
request<ApiResponse<ProductionPlanInfo>>(`/api/v1/production/plan/${planId}`, { method: 'GET' });
|
||||
|
||||
export const confirmPlan = (data: { planId: string }) =>
|
||||
request<ApiResponse<null>>('/api/v1/production/plan/confirm', { method: 'POST', data });
|
||||
|
||||
export const createShareLink = (data: { planId: string; expiresHours?: number; hideSensitive?: number }) =>
|
||||
request<ApiResponse<ShareLinkInfo>>('/api/v1/production/share', { method: 'POST', data });
|
||||
|
||||
export const completeStep = (data: { planId: string; stepType: string }) =>
|
||||
request<ApiResponse<null>>('/api/v1/production/process/step', { method: 'POST', data });
|
||||
|
||||
export const createInbound = (data: { planId: string; batchNo: string; quantity: string; rolls: number; pricePerMeter: string; remark?: string }) =>
|
||||
request<ApiResponse<{ id: string }>>('/api/v1/production/inbound', { method: 'POST', data });
|
||||
|
||||
export const listInbound = (params: { planId: string; page: number; pageSize: number }) =>
|
||||
request<ApiResponse<{ total: number; list: InboundRecordInfo[]; totalQuantity: number; totalRolls: number }>>('/api/v1/production/inbound', { method: 'GET', params });
|
||||
|
||||
// ── Public Share Link (no JWT) ───────────────────
|
||||
|
||||
export const getShareLinkPlanView = (shortCode: string) =>
|
||||
request<ApiResponse<ShareLinkPlanView>>(`/api/v1/public/production/share/${shortCode}`, { method: 'GET' });
|
||||
|
||||
export const clickShareLink = (data: { shortCode: string }) =>
|
||||
request<ApiResponse<null>>('/api/v1/public/production/share/click', { method: 'POST', data });
|
||||
|
||||
// ── Share Link Actions (JWT required) ────────────
|
||||
|
||||
export const confirmShareLink = (data: { shortCode: string }) =>
|
||||
request<ApiResponse<null>>('/api/v1/production/share/confirm', { method: 'POST', data });
|
||||
|
||||
export const rejectShareLink = (data: { shortCode: string; rejectReason: string }) =>
|
||||
request<ApiResponse<null>>('/api/v1/production/share/reject', { method: 'POST', data });
|
||||
|
||||
@ -441,92 +441,3 @@ export interface PurchaseStatsByProductItem {
|
||||
avgUnitPrice: string;
|
||||
totalAmount: string;
|
||||
}
|
||||
|
||||
// ── Production ───────────────────────────────────
|
||||
|
||||
export interface ProductionPlanInfo {
|
||||
planId: string;
|
||||
tenantId: string;
|
||||
planCode: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
color: string;
|
||||
fabricCode: string;
|
||||
colorCode: string;
|
||||
targetQuantity: string;
|
||||
completedQuantity: string;
|
||||
yarnUsagePerMeter: string;
|
||||
productionPrice: string;
|
||||
supplierId: string;
|
||||
status: number;
|
||||
remark: string;
|
||||
startTime: string;
|
||||
creator: string;
|
||||
operator: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
yarnRatios: YarnRatioInfo[];
|
||||
processSteps: ProcessStepInfo[];
|
||||
inboundRecords: InboundRecordInfo[];
|
||||
}
|
||||
|
||||
export interface YarnRatioInfo {
|
||||
ratioId: string;
|
||||
planId: string;
|
||||
yarnName: string;
|
||||
ratio: string;
|
||||
amountPerMeter: string;
|
||||
totalAmount: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProcessStepInfo {
|
||||
stepId: string;
|
||||
planId: string;
|
||||
stepType: string;
|
||||
stepOrder: number;
|
||||
status: number;
|
||||
operatorId: string;
|
||||
completedAt: string;
|
||||
remark: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InboundRecordInfo {
|
||||
recordId: string;
|
||||
tenantId: string;
|
||||
planId: string;
|
||||
productId: string;
|
||||
batchNo: string;
|
||||
quantity: string;
|
||||
rolls: number;
|
||||
pricePerMeter: string;
|
||||
operatorId: string;
|
||||
remark: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ShareLinkInfo {
|
||||
linkId: string;
|
||||
tenantId: string;
|
||||
planId: string;
|
||||
shortCode: string;
|
||||
token: string;
|
||||
factoryType: string;
|
||||
hideSensitive: number;
|
||||
status: number;
|
||||
clickCount: number;
|
||||
clickedAt: string;
|
||||
respondedAt: string;
|
||||
rejectReason: string;
|
||||
expiresAt: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShareLinkPlanView {
|
||||
plan: ProductionPlanInfo;
|
||||
link: ShareLinkInfo;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user