Files
xin-procurement/web/pages/purchase/order.tsx
T
2026-08-20 16:48:04 +08:00

1673 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Empty,
Form,
Image,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Radio,
Select,
Space,
Spin,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import {AccountBookOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type {
IBill,
IBillPrepare,
IPurchaseCell,
IPurchaseCellItem,
IPurchaseDetail,
IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
IPurchaseSupplierItem,
PurchaseStoreItemAddParams,
PurchaseStoreItemUpdateParams,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
addPurchaseStoreItem,
exportPurchase,
exportPurchaseStores,
exportPurchaseSuppliers,
generateBill,
getBillPrepare,
getPurchaseCell,
getPurchaseDetail,
getPurchaseStoreSummary,
removePurchaseStoreItem,
type BillGenerateStoreParams,
type PurchaseCellUpdateParams,
type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseRow,
updatePurchaseStoreItem,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import { getProductOptions } from '@/api/product/goods.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import type IProduct from '@/domain/iProduct.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
const calcUnitRefPrice = (total: number, spec: string): number => {
const pack = parseFloat(spec);
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
};
/**
* 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改)
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
*/
const PurchaseOrderPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
// 详情抽屉
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IPurchaseDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [completing, setCompleting] = useState(false);
// 行修改弹窗
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
const [rowSaving, setRowSaving] = useState(false);
const [editForm] = Form.useForm<PurchaseRowUpdateParams>();
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 单元格下钻弹窗(门店 × 商品订货明细)
const [cellOpen, setCellOpen] = useState(false);
const [cellLoading, setCellLoading] = useState(false);
const [cellData, setCellData] = useState<IPurchaseCell | null>(null);
const [cellQuery, setCellQuery] = useState<{ productId: number; storeId: number } | null>(null);
// 单元格明细编辑(数量/称重)
const [cellItemOpen, setCellItemOpen] = useState(false);
const [cellItemTarget, setCellItemTarget] = useState<IPurchaseCellItem | null>(null);
const [cellItemSaving, setCellItemSaving] = useState(false);
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
// 门店购买详情(按商品聚合的门店采购汇总)
const [detailTab, setDetailTab] = useState('items');
const [storeId, setStoreId] = useState<number>(0);
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false);
// 门店购买详情:新增单品弹窗
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemSaving, setAddItemSaving] = useState(false);
const [addItemForm] = Form.useForm<PurchaseStoreItemAddParams>();
const [productOptions, setProductOptions] = useState<IProduct[]>([]);
// 门店购买详情:单品编辑弹窗(数量/称重/单价)
const [storeItemTarget, setStoreItemTarget] = useState<IPurchaseStoreItem | null>(null);
const [storeItemSaving, setStoreItemSaving] = useState(false);
const [storeItemForm] = Form.useForm<PurchaseStoreItemUpdateParams>();
// 供应商采购明细页签
const [supplierId, setSupplierId] = useState<number>(0);
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
const [itemExportOpen, setItemExportOpen] = useState(false);
const [itemExportSupplier, setItemExportSupplier] = useState<number>(0);
const [storeExportOpen, setStoreExportOpen] = useState(false);
const [storeExportScope, setStoreExportScope] = useState<'current' | 'all'>('current');
const [supplierExportOpen, setSupplierExportOpen] = useState(false);
const [supplierExportScope, setSupplierExportScope] = useState<'current' | 'all'>('current');
/** 采购单内出现的供应商(矩阵/供应商页签筛选与导出选项共用) */
const purchaseSuppliers = useMemo(() => {
const map = new Map<number, string>();
(detail?.items ?? []).forEach((row) => {
if (row.supplier_id > 0 && row.supplier?.name) {
map.set(row.supplier_id, row.supplier.name);
}
});
return Array.from(map, ([id, name]) => ({ id, name }));
}, [detail?.items]);
/** 供应商采购明细行:按供应商过滤矩阵行,金额=数量×成本价(成本口径) */
const supplierItems = useMemo<IPurchaseSupplierItem[]>(() => {
if (!detail || supplierId <= 0) return [];
return detail.items
.filter((row) => row.supplier_id === supplierId)
.map((row) => ({
product_id: row.product_id,
product_name: row.product_name,
product_spec: row.product_spec,
unit: row.unit,
market: row.market,
cost_price: String(row.cost_price),
quantity: row.quantity,
weight: String(row.weight),
amount: (row.quantity * Number(row.cost_price)).toFixed(2),
}));
}, [detail, supplierId]);
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
const [billOpen, setBillOpen] = useState(false);
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
const [billLoading, setBillLoading] = useState(false);
const [billSaving, setBillSaving] = useState(false);
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加
const watchBillStores = Form.useWatch('stores', billForm) ?? [];
const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
const input = watchBillStores[index] ?? {};
const deliveryFee = Number(input.delivery_fee ?? 0);
const added =
Number(input.box_num ?? 0) * Number(row.box_price) +
Number(input.tray_num ?? 0) * Number(row.tray_price);
return {
added,
total: Number(row.product_amount) + deliveryFee + added,
};
});
const billAllGenerated =
(billPrepare?.stores ?? []).length > 0 && billPrepare!.stores.every((row) => row.bill !== null);
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
// 单元格下钻弹窗打开时加载明细
useEffect(() => {
if (cellOpen && cellQuery) {
loadCell();
}
}, [cellOpen, cellQuery]);
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
useEffect(() => {
if (detail && detail.stores.length > 0) {
setStoreId((prev) => (detail.stores.some((s) => s.id === prev) ? prev : detail.stores[0].id));
}
}, [detail]);
// 详情加载后默认选中第一个供应商(当前选中供应商仍在采购单内则保留)
useEffect(() => {
if (purchaseSuppliers.length > 0) {
setSupplierId((prev) => (purchaseSuppliers.some((s) => s.id === prev) ? prev : purchaseSuppliers[0].id));
} else {
setSupplierId(0);
}
}, [purchaseSuppliers]);
// 切到「门店购买详情」页签或切换门店时加载汇总
useEffect(() => {
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
void loadStoreSummary();
}
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await getPurchaseDetail(id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const openDetail = async (id: number) => {
setDetailTab('items');
setStoreSummary(null);
setDetailOpen(true);
await loadDetail(id);
};
/** 加载门店购买详情(门店采购汇总) */
const loadStoreSummary = async () => {
if (!detail || storeId <= 0) {
return;
}
setStoreLoading(true);
try {
const res = await getPurchaseStoreSummary(detail.purchase.id!, storeId);
setStoreSummary(res.data.data ?? null);
} finally {
setStoreLoading(false);
}
};
/** 门店单品增删改后:刷新门店汇总、采购单详情与列表 */
const refreshAfterStoreItemChange = async () => {
await loadStoreSummary();
if (detail) {
await loadDetail(detail.purchase.id!);
}
await tableRef.current?.reload();
};
/** 打开新增单品弹窗(懒加载商品选项) */
const openAddItem = () => {
addItemForm.resetFields();
setAddItemOpen(true);
if (productOptions.length === 0) {
getProductOptions().then((res) => setProductOptions(res.data.data ?? []));
}
};
/** 提交新增单品 */
const handleAddItemSave = async (values: PurchaseStoreItemAddParams) => {
if (!detail || storeId <= 0) {
return;
}
setAddItemSaving(true);
try {
await addPurchaseStoreItem(detail.purchase.id!, storeId, values);
message.success('已添加单品,订货单与采购单汇总已重算');
setAddItemOpen(false);
await refreshAfterStoreItemChange();
} finally {
setAddItemSaving(false);
}
};
/** 打开单品编辑弹窗(回显加权平均单价/数量/称重) */
const openStoreItemEdit = (row: IPurchaseStoreItem) => {
setStoreItemTarget(row);
storeItemForm.setFieldsValue({
quantity: row.quantity,
price: Number(row.price ?? 0),
weight: Number(row.weight ?? 0),
});
};
/** 提交单品修改:同商品多笔订单明细合并到最早一条,级联重算 */
const handleStoreItemSave = async (values: PurchaseStoreItemUpdateParams) => {
if (!detail || !storeItemTarget || storeId <= 0) {
return;
}
setStoreItemSaving(true);
try {
await updatePurchaseStoreItem(detail.purchase.id!, storeId, storeItemTarget.product_id, values);
message.success('单品已更新,订货单与采购单汇总已重算');
setStoreItemTarget(null);
await refreshAfterStoreItemChange();
} finally {
setStoreItemSaving(false);
}
};
/** 移除单品 */
const handleStoreItemRemove = async (row: IPurchaseStoreItem) => {
if (!detail || storeId <= 0) {
return;
}
await removePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id);
message.success('已移除单品,订货单与采购单汇总已重算');
await refreshAfterStoreItemChange();
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
setCellQuery({ productId: row.product_id, storeId: store.id });
setCellData(null);
setCellOpen(true);
};
/** 加载单元格明细 */
const loadCell = async () => {
if (!detail || !cellQuery) {
return;
}
setCellLoading(true);
try {
const res = await getPurchaseCell(detail.purchase.id!, cellQuery.productId, cellQuery.storeId);
setCellData(res.data.data ?? null);
} finally {
setCellLoading(false);
}
};
/** 单元格明细修改/同步后:刷新弹窗、采购单详情与列表 */
const refreshAfterCellChange = async () => {
await loadCell();
if (detail) {
await loadDetail(detail.purchase.id!);
}
await tableRef.current?.reload();
};
const openCellItemEdit = (item: IPurchaseCellItem) => {
setCellItemTarget(item);
cellItemForm.setFieldsValue({
quantity: item.quantity,
price: Number(item.price ?? 0),
weight: Number(item.weight ?? 0),
});
setCellItemOpen(true);
};
/** 提交单元格明细修改:级联重算明细金额、订货单与采购单汇总 */
const handleCellItemSave = async (values: PurchaseCellUpdateParams) => {
if (!cellItemTarget?.id) {
return;
}
setCellItemSaving(true);
try {
await updatePurchaseCellItem(cellItemTarget.id, values);
message.success('明细已更新,订货单与采购单汇总已重算');
setCellItemOpen(false);
setCellItemTarget(null);
await refreshAfterCellChange();
} finally {
setCellItemSaving(false);
}
};
const openEdit = (row: IPurchaseDetailRow) => {
setEditingRow(row);
editForm.setFieldsValue({
product_name: row.product_name,
supplier_id: row.supplier_id > 0 ? row.supplier_id : undefined,
product_spec: row.product_spec,
unit: row.unit,
cost_price: Number(row.cost_price),
});
};
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
const handleEditSave = async (values: PurchaseRowUpdateParams) => {
if (!detail || !editingRow) {
return;
}
setRowSaving(true);
try {
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
...values,
supplier_id: values.supplier_id ?? 0,
});
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
setEditingRow(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setRowSaving(false);
}
};
/** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转筐/托盘数量 */
const openBillGenerate = async (id: number) => {
setBillOpen(true);
setBillLoading(true);
setBillPrepare(null);
try {
const res = await getBillPrepare(id);
const data = res.data.data ?? null;
setBillPrepare(data);
billForm.setFieldsValue({
stores: (data?.stores ?? []).map((row) => ({
store_id: row.store_id,
delivery_fee: row.bill ? Number(row.bill.delivery_fee) : 0,
box_num: row.bill ? row.bill.box_num : 0,
tray_num: row.bill ? row.bill.tray_num : 0,
})),
});
} finally {
setBillLoading(false);
}
};
/** 提交生成账单:按门店各生成一张并关联采购单全部订单,金额由系统汇总不可修改 */
const handleBillSave = async (values: { stores: BillGenerateStoreParams[] }) => {
if (!billPrepare) {
return;
}
setBillSaving(true);
try {
const res = await generateBill(billPrepare.purchase.id, values.stores);
message.success(`已生成 ${res.data.data?.count ?? 0} 张门店账单,采购单订单已关联`);
setBillOpen(false);
setBillPrepare(null);
await tableRef.current?.reload();
if (detail && detail.purchase.id === billPrepare.purchase.id) {
await loadDetail(detail.purchase.id!);
}
} finally {
setBillSaving(false);
}
};
const handleComplete = async (id: number) => {
setCompleting(true);
try {
await Update(`/purchase/order/${id}/finish`, { status: 3 });
message.success('采购单已标记完成');
await tableRef.current?.reload();
} finally {
setCompleting(false);
}
};
/** 明细矩阵列:品名/供应商/参考零售价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' },
{
title: '供应商',
dataIndex: 'supplier',
width: 110,
align: 'center',
render: (_, row) => row.supplier?.name ?? '-',
},
{ title: '市场', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '参考零售价',
key: 'retail_price',
width: 100,
align: 'center',
render: (_, row) => {
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
const quantity = Number(row.quantity ?? 0);
if (quantity <= 0) return <Text type="secondary">-</Text>;
const weightedPrice = Number(row.amount ?? 0) / quantity;
return ${calcUnitRefPrice(weightedPrice, row.product_spec ?? '').toFixed(2)}`;
},
},
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
{ title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => ${Number(v).toFixed(2)}` },
];
const storeColumns = (detail?.stores ?? []).map((store) => ({
title: <span className={'text-[red]'}>{store.name}</span>,
key: `store-${store.id}`,
width: 100,
align: 'center' as const,
render: (_: unknown, row: IPurchaseDetailRow) => {
const quantity = row.cells[store.id];
return quantity !== undefined ? (
<Typography.Link onClick={() => openCell(row, store)}>{quantity}</Typography.Link>
) : (
<Text type="secondary">-</Text>
);
},
}));
const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{
title: '合计数量',
key: 'total_quantity',
width: 90,
align: 'center',
render: (_, row) => <Text strong>{row.quantity}</Text>,
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
align: 'center',
render: (_, row) => (
<>
{ detail?.purchase.status === 0 ? (
<AuthButton auth="purchase.order.update">
<Button
size="small"
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(row)}
>
修改
</Button>
</AuthButton>
) : '-' }
</>
),
},
];
return [...fixed, ...storeColumns, ...tail];
};
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计 */
const renderSummary = () => {
const stores = detail?.stores ?? [];
const items = detail?.items ?? [];
const storeTotals = stores.map((store) =>
items.reduce(
(sum, row) => sum + (row.cells[store.id] ?? 0) * Number(row.cost_price),
0,
),
);
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalAmount = storeTotals.reduce((sum, amount) => sum + amount, 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={7} align="center">
<Text strong>成本统计(按门店)</Text>
</Table.Summary.Cell>
{storeTotals.map((amount, index) => (
<Table.Summary.Cell key={stores[index].id} index={7 + index} align="center">
<Text strong>¥{amount.toFixed(2)}</Text>
</Table.Summary.Cell>
))}
<Table.Summary.Cell index={7 + stores.length} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={8 + stores.length} align="center">
<Text strong>¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 门店购买详情列:商品/市场/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
{ title: '市场', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '参考零售价',
key: 'retail_price',
width: 130,
align: 'center',
render: (_, row) => ${calcUnitRefPrice(Number(row.price), row.product_spec).toFixed(2)}`,
},
{ title: '包规', dataIndex: 'product_spec', width: 90, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '单价',
dataIndex: 'price',
width: 100,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 90,
align: 'center',
render: (v) => <Text strong>{v}</Text>,
},
{ title: '重量', dataIndex: 'weight', width: 90, align: 'center', render: (v) => `${v || '-'}斤` },
{
title: '预计金额',
dataIndex: 'amount',
width: 110,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
{
title: '操作',
key: 'action',
width: 130,
align: 'center',
render: (_, row) =>
detail?.purchase.status === 0 ? (
<Space size={0}>
<AuthButton auth="purchase.order.update">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openStoreItemEdit(row)}
>
编辑
</Button>
</AuthButton>
<AuthButton auth="purchase.order.update">
<Popconfirm
title="移除该单品?"
description={`将从「${storeSummary?.store?.name ?? '该门店'}」采购明细中移除「${row.product_name}」`}
onConfirm={() => handleStoreItemRemove(row)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
移除
</Button>
</Popconfirm>
</AuthButton>
</Space>
) : (
<Text type="secondary">-</Text>
),
},
];
/** 门店购买详情合计行:总数量 + 总预计金额 */
const renderStoreTotal = () => {
const items = storeSummary?.items ?? [];
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={6} align="center">
<Text strong>合计</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={8} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={9} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 供应商采购明细列:品名/市场/包规/单位/成本价/数量/重量/金额(成本口径) */
const supplierColumns: TableProps<IPurchaseSupplierItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name', width: 180, align: 'center' },
{ title: '市场', dataIndex: 'market', width: 100, align: 'center', render: (v) => v || '-' },
{ title: '包规', dataIndex: 'product_spec', width: 110, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '成本价',
dataIndex: 'cost_price',
width: 110,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 100,
align: 'center',
render: (v) => <Text strong>{v}</Text>,
},
{ title: '重量', dataIndex: 'weight', width: 110, align: 'center', render: (v) => `${Number(v).toFixed(3)}斤` },
{
title: '金额',
dataIndex: 'amount',
width: 120,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
];
/** 供应商采购明细合计行 */
const renderSupplierTotal = () => {
const totalQuantity = supplierItems.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalWeight = supplierItems.reduce((sum, row) => sum + Number(row.weight), 0);
const totalAmount = supplierItems.reduce((sum, row) => sum + Number(row.amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={5} align="center">
<Text strong>合计</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 门店账单列:门店/账单号/账单日期/商品金额/配送费/周转筐/托盘/附加金额/总金额 */
const billColumns: TableProps<IBill>['columns'] = [
{ title: '门店', dataIndex: 'store_name', width: 140, align: 'center' },
{
title: '账单号',
dataIndex: 'bill_no',
width: 180,
align: 'center',
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
},
{ title: '账单日期', dataIndex: 'bill_date', width: 110, align: 'center' },
{
title: '商品金额',
dataIndex: 'product_amount',
width: 110,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '配送费',
dataIndex: 'delivery_fee',
width: 100,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '周转筐',
key: 'box',
width: 110,
align: 'center',
render: (_, row) => `${row.box_num} × ¥${Number(row.box_price).toFixed(2)}`,
},
{
title: '周转托盘',
key: 'tray',
width: 110,
align: 'center',
render: (_, row) => `${row.tray_num} × ¥${Number(row.tray_price).toFixed(2)}`,
},
{
title: '附加金额',
dataIndex: 'added_amount',
width: 100,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '账单总金额',
dataIndex: 'total_amount',
width: 120,
align: 'center',
render: (v) => <Text strong type="danger">¥{Number(v).toFixed(2)}</Text>,
},
{
title: '支付状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (v) => {
const item = BILL_STATUS_MAP[Number(v ?? 0)];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
];
/** 门店账单合计行 */
const renderBillSummary = () => {
const bills = detail?.bills ?? [];
const totalProduct = bills.reduce((sum, row) => sum + Number(row.product_amount), 0);
const totalDelivery = bills.reduce((sum, row) => sum + Number(row.delivery_fee), 0);
const totalAdded = bills.reduce((sum, row) => sum + Number(row.added_amount), 0);
const totalAmount = bills.reduce((sum, row) => sum + Number(row.total_amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3} align="center">
<Text strong>合计</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="center">
<Text strong>¥{totalProduct.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong>¥{totalDelivery.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} colSpan={2} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong>¥{totalAdded.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={8} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={9} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
const columns: XinTableColumn<IPurchaseOrder>[] = [
{
title: '采购单号',
dataIndex: 'purchase_no',
valueType: 'text',
hideInForm: true,
align: 'center',
width: 260,
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
},
{
title: '采购日期',
dataIndex: 'purchase_date',
valueType: 'date',
align: 'center',
hideInForm: true,
render: (_, record) => record.purchase_date,
},
{
title: '预估成本',
dataIndex: 'estimate_amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => ${record.estimate_amount}`,
},
{
title: '实际成本',
dataIndex: 'actual_amount',
hideInSearch: true,
valueType: "digit",
fieldProps: {
min: 0,
precision: 3,
placeholder: "请输入单价"
},
align: 'center',
render: (_, record) =>
Number(record.actual_amount) > 0 ? (
<Text strong>¥{record.actual_amount}</Text>
) : (
<Text type="secondary">未录入</Text>
),
},
{
title: '总件数',
dataIndex: 'total_quantity',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: "textarea",
hideInTable: true,
hideInSearch: true
},
{
title: '总重量',
dataIndex: 'total_weight',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => `${record.total_weight}斤`,
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(PURCHASE_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = PURCHASE_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '制单人',
dataIndex: 'operator',
hideInForm: true,
hideInSearch: true,
render: (_, record) => record.operator?.nickname ?? '-',
align: 'center',
},
];
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record, dom) => [
record.status === 0 ? dom.edit : null,
<Button key="detail" size="small" icon={<UnorderedListOutlined />} type={'primary'} onClick={() => openDetail(record.id!)} />,
record.status === 0 ? (
<AuthButton auth="purchase.order.update">
<Popconfirm title="确认该采购单已完成?" description="已完成采购单将锁定,不能再修改订单信息" onConfirm={() => handleComplete(record.id!)}>
<Button type={'primary'} size="small" variant={'solid'} color={'green'} loading={completing}>
完成采购
</Button>
</Popconfirm>
</AuthButton>
) : (
<AuthButton auth="purchase.order.bill">
<Button
type={'primary'}
size="small"
variant={'solid'}
color={'orange'}
icon={<AccountBookOutlined />}
onClick={() => openBillGenerate(record.id!)}
>
生成账单
</Button>
</AuthButton>
)
];
const tableProps: XinTableProps<IPurchaseOrder> = {
api: '/purchase/order',
columns,
rowKey: 'id',
accessName: 'purchase.order',
tableRef,
operateRender,
operateProps: {
width: 300
},
formProps: false,
};
return (
<>
<div className="mb-5">
<Title level={3}>采购单</Title>
<Text type="secondary">
在「门店订单」页勾选已接单订单合并生成采购单;采购单支持修改订单信息。
</Text>
</div>
<XinTable<IPurchaseOrder> {...tableProps} />
{/* 采购单详情:商品行 × 门店列矩阵 */}
<Drawer
title={detail ? `采购单 ${detail.purchase.purchase_no}` : '采购单详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
size={1200}
loading={detailLoading}
>
{detail && (
<>
<Title level={5} className="mb-3!">
采购单信息
</Title>
<Descriptions column={3} size="small" bordered>
<Descriptions.Item label="采购日期">{detail.purchase.purchase_date}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.color}>
{PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="制单人">
{detail.purchase.operator?.nickname ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="预估成本">¥{detail.purchase.estimate_amount}</Descriptions.Item>
<Descriptions.Item label="实际成本">¥{detail.purchase.actual_amount}</Descriptions.Item>
<Descriptions.Item label="总件数">{detail.purchase.total_quantity}</Descriptions.Item>
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{detail.purchase.created_at}</Descriptions.Item>
<Descriptions.Item label="备注" span={3}>{detail.purchase.remark}</Descriptions.Item>
</Descriptions>
<Tabs
activeKey={detailTab}
onChange={setDetailTab}
className="mt-3!"
items={[
{ key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' },
{ key: 'suppliers', label: '供应商采购明细' },
{ key: 'bills', label: '门店账单' },
]}
/>
{ detailTab === 'stores' ? (
<>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Text>选择门店:</Text>
<Select
value={storeId || undefined}
onChange={(value) => setStoreId(value)}
placeholder="选择门店"
className="w-60!"
showSearch
optionFilterProp="label"
options={detail.stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</div>
<Space>
{detail.purchase.status === 0 && (
<AuthButton auth="purchase.order.update">
<Button icon={<PlusOutlined />} onClick={openAddItem}>
新增单品
</Button>
</AuthButton>
)}
<AuthButton auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => {
setStoreExportScope('current');
setStoreExportOpen(true);
}}
>
导出
</Button>
</AuthButton>
</Space>
</div>
<Spin spinning={storeLoading}>
{storeSummary && storeSummary.items.length > 0 ? (
<Table<IPurchaseStoreItem>
rowKey="product_id"
size="small"
bordered
columns={storeColumns}
dataSource={storeSummary.items}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderStoreTotal}
/>
) : (
!storeLoading && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该门店在此采购单中无采购商品"
className="py-8!"
/>
)
)}
</Spin>
</>
) : detailTab === 'suppliers' ? (
<>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Text>选择供应商:</Text>
<Select
value={supplierId || undefined}
onChange={(value) => setSupplierId(value)}
placeholder="选择供应商"
className="w-60!"
showSearch
optionFilterProp="label"
options={purchaseSuppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
</div>
<AuthButton auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => {
setSupplierExportScope('current');
setSupplierExportOpen(true);
}}
>
导出
</Button>
</AuthButton>
</div>
{supplierItems.length > 0 ? (
<Table<IPurchaseSupplierItem>
rowKey="product_id"
size="small"
bordered
columns={supplierColumns}
dataSource={supplierItems}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderSupplierTotal}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该供应商在此采购单中无采购明细"
className="py-8!"
/>
)}
</>
) : detailTab === 'bills' ? (
detail.bills.length > 0 ? (
<Table<IBill>
rowKey="id"
size="small"
bordered
columns={billColumns}
dataSource={detail.bills}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderBillSummary}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={detail.purchase.status === 0 ? '采购单完成后可在列表操作列生成账单' : '该采购单暂未生成账单'}
className="py-8!"
/>
)
) : (
<>
<Space style={{ marginBottom: 20 }}>
<AuthButton key="export" auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => {
setItemExportSupplier(0);
setItemExportOpen(true);
}}
>
导出
</Button>
</AuthButton>
</Space>
<Table<IPurchaseDetailRow>
rowKey="product_id"
size="small"
bordered
columns={buildItemColumns()}
dataSource={detail.items}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderSummary}
/>
</>
) }
</>
)}
</Drawer>
{/* 行修改:品名/供应商/包规/单位/成本 */}
<Modal
title={editingRow ? `修改「${editingRow.product_name}」` : '修改明细行'}
open={editingRow !== null}
onCancel={() => setEditingRow(null)}
destroyOnHidden
footer={[
<Button key="cancel" onClick={() => setEditingRow(null)}>
取消
</Button>,
<Button key="purchase" type="primary" loading={rowSaving} onClick={() => editForm.submit()}>
保存
</Button>
]}
>
<div className="py-2 text-gray-500">
保存将同步修改本采购单中该商品的所有订单项,并同步保存到商品档案(商品列表);修改成本时,各门店单价按等级上浮比例自动重算。
</div>
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
<Form.Item
label="品名"
name="product_name"
rules={[{ required: true, message: '请输入品名' }, { max: 100 }]}
>
<Input maxLength={100} />
</Form.Item>
<Form.Item
label="供应商"
name="supplier_id"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
allowClear
showSearch={{ optionFilterProp: 'label' }}
placeholder="选择供应商"
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item
label="包规"
name="product_spec"
rules={[{ required: true, message: '请输入包规' }, { max: 100 }]}
>
<Input maxLength={100} placeholder="如:10斤/箱" />
</Form.Item>
<Form.Item
label="单位"
name="unit"
rules={[{ required: true, message: '请输入单位' },{ max: 20 }]}
>
<Input maxLength={20} placeholder="如:斤" />
</Form.Item>
<Form.Item
label="成本"
name="cost_price"
rules={[{ required: true, message: '请输入成本' }]}
>
<InputNumber min={0} precision={2} prefix="¥" className="w-full" />
</Form.Item>
</Form>
</Modal>
{/* 单元格下钻 */}
<Modal
title={cellData ? `${cellData.store?.name ?? ''} · ${cellData.product?.name ?? ''}` : '门店商品明细'}
open={cellOpen}
onCancel={() => setCellOpen(false)}
footer={null}
width={1000}
destroyOnHidden
styles={{ body: {paddingTop: 16} }}
>
<Spin spinning={cellLoading}>
{cellData && (
<>
{cellData.items.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该门店无此商品明细"
className="py-8!"
/>
) : (
<div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="flex-1">商品信息</div>
<div className="w-30 shrink-0 text-center">单价</div>
<div className="w-26 shrink-0 text-center">订货量</div>
<div className="w-33 shrink-0 text-center">订货金额</div>
<div className="w-33 shrink-0 text-center">重量</div>
<div className="w-40 shrink-0 text-center">操作</div>
</div>
{cellData.items.map((item) => (
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center">
<Image.PreviewGroup>
{item.image ? (
<Image
src={item.image}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
暂无图片
</div>
)}
</Image.PreviewGroup>
<div className="ml-3 min-w-0">
<div className="text-sm font-medium">
{item.product_name}
<Tag
className="ml-2!"
color={STORE_ORDER_STATUS_MAP[item.order_status]?.color}
>
{STORE_ORDER_STATUS_MAP[item.order_status]?.text}
</Tag>
</div>
<div className="mt-0.5 text-xs text-gray-500">
订单:{item.order_no}
</div>
{item.remark ? (
<div className="mt-0.5 truncate text-xs text-gray-500">
备注:{item.remark}
</div>
) : null}
</div>
</div>
<div className="w-30 shrink-0 text-center">¥{item.price}</div>
<div className="w-26 shrink-0 text-center">{item.quantity}</div>
<div className="w-33 shrink-0 text-center">
<Text strong>¥{item.amount}</Text>
</div>
<div className="w-33 shrink-0 text-center">
{item.weight ?? '-'}
</div>
<div className="w-40 shrink-0 text-center">
{detail?.purchase.status === 0 ? (
<Space size={0}>
<AuthButton auth="purchase.order.update">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openCellItemEdit(item)}
>
编辑
</Button>
</AuthButton>
</Space>
) : (
<Text type="secondary">-</Text>
)}
</div>
</div>
))}
</div>
)}
{cellData.items.length > 0 && (
<div className="mt-3! flex justify-end gap-6 text-sm">
<Text type="secondary">
合计数量:
<Text strong>{cellData.items.reduce((sum, item) => sum + item.quantity, 0)}</Text>
</Text>
<Text type="secondary">
合计订货金额:
<Text strong type="danger">
¥
{cellData.items
.reduce((sum, item) => sum + Number(item.amount), 0)
.toFixed(2)}
</Text>
</Text>
</div>
)}
</>
)}
</Spin>
</Modal>
{/* 单元格明细编辑 */}
<Modal
title={cellItemTarget ? `编辑「${cellItemTarget.product_name}」` : '编辑明细'}
open={cellItemOpen}
onCancel={() => {
setCellItemOpen(false);
setCellItemTarget(null);
}}
onOk={() => cellItemForm.submit()}
confirmLoading={cellItemSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
订单 {cellItemTarget?.order_no};修改保存后,系统将自动重算明细金额、订货单与采购单汇总。
</div>
<Form form={cellItemForm} layout="vertical" onFinish={handleCellItemSave}>
<Form.Item
label="单价(元)"
name="price"
rules={[{ required: true, message: '请输入单价' }]}
>
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
</Form.Item>
<Form.Item
label="订货量"
name="quantity"
rules={[{ required: true, message: '请输入订货量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入订货量" />
</Form.Item>
<Form.Item label="称重" name="weight" rules={[{ required: true, message: '请输入称重' }]}>
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
{/* 生成账单:按门店填写配送费/周转筐/托盘数量(商品金额只读,由订单汇总) */}
<Modal
title={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
open={billOpen}
onCancel={() => {
setBillOpen(false);
setBillPrepare(null);
}}
onOk={() => billForm.submit()}
confirmLoading={billSaving}
okText="确认生成"
okButtonProps={{ disabled: billAllGenerated }}
width={960}
destroyOnHidden
>
<Spin spinning={billLoading}>
{billPrepare && (
<>
<div className="py-2 text-gray-500">
每个门店单独生成一张账单;商品金额由订单汇总不可修改,请填写各门店的配送费与周转筐/托盘数量(正数=压筐附加金额,负数=回筐抵扣金额)。
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div>
<Form form={billForm} layout="vertical" onFinish={handleBillSave}>
<Form.List name="stores">
{(fields) => (
<div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="w-40 shrink-0">门店</div>
<div className="w-28 shrink-0 text-center">商品金额</div>
<div className="w-32 shrink-0 text-center">配送费(元)</div>
<div className="w-32 shrink-0 text-center">周转筐(¥{billPrepare.stores[0]?.box_price ?? '0.00'}/个)</div>
<div className="w-32 shrink-0 text-center">周转托盘(¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/个)</div>
<div className="w-28 shrink-0 text-center">附加金额</div>
<div className="flex-1 text-center">账单总金额</div>
</div>
{fields.map((field) => {
const row = billPrepare.stores[field.name];
const billed = row?.bill != null;
return (
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
<div className="w-40 shrink-0 pr-2 text-sm">
<div>{row?.store_name ?? '-'}</div>
<div className="text-xs text-gray-400">
{row?.order_count ?? 0} 笔订单
{billed && <Tag className="ml-1!" color="success">已生成</Tag>}
</div>
</div>
<div className="w-28 shrink-0 text-center">
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
</div>
<Form.Item name={[field.name, 'store_id']} hidden>
<Input />
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'delivery_fee']}
rules={[{ required: true, message: '请输入配送费' }]}
>
<InputNumber
className="w-full"
min={0}
precision={2}
disabled={billed}
placeholder="配送费"
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转筐数量' }]}
>
<InputNumber
className="w-full"
precision={0}
disabled={billed}
placeholder="正压负回"
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
<InputNumber
className="w-full"
precision={0}
disabled={billed}
placeholder="正压负回"
/>
</Form.Item>
<div className="w-28 shrink-0 text-center">
¥{(billed ? Number(row.bill!.added_amount) : billPreview[field.name]?.added ?? 0).toFixed(2)}
</div>
<div className="flex-1 text-center">
<Text strong type="danger">
¥{(billed ? Number(row.bill!.total_amount) : billPreview[field.name]?.total ?? 0).toFixed(2)}
</Text>
{billed && (
<div className="text-xs text-gray-400">{row.bill!.bill_no}</div>
)}
</div>
</div>
);
})}
</div>
)}
</Form.List>
</Form>
</>
)}
</Spin>
</Modal>
{/* 新增单品:选择商品 + 采购数量 + 称重 */}
<Modal
title={`新增单品 · ${storeSummary?.store?.name ?? ''}`}
open={addItemOpen}
onCancel={() => setAddItemOpen(false)}
onOk={() => addItemForm.submit()}
confirmLoading={addItemSaving}
okText="添加"
destroyOnHidden
>
<div className="py-2 text-gray-500">
单品将挂靠到该门店在此采购单中的最新一笔订单;单价按门店等级上浮比例自动换算。
</div>
<Form form={addItemForm} layout="vertical" onFinish={handleAddItemSave}>
<Form.Item
label="商品"
name="product_id"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select
showSearch={{ optionFilterProp: 'label' }}
placeholder="搜索并选择商品"
options={productOptions.map((p) => ({
value: p.id!,
label: `${p.name}${p.spec ? `${p.spec}` : ''}`,
}))}
/>
</Form.Item>
<Form.Item
label="采购数量"
name="quantity"
rules={[{ required: true, message: '请输入采购数量' }]}
>
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入采购数量(包数)" />
</Form.Item>
<Form.Item label="称重(斤,可选)" name="weight">
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
{/* 单品编辑:数量/称重/单价(同商品多笔订单明细合并到最早一条) */}
<Modal
title={storeItemTarget ? `编辑「${storeItemTarget.product_name}」` : '编辑单品'}
open={storeItemTarget !== null}
onCancel={() => setStoreItemTarget(null)}
onOk={() => storeItemForm.submit()}
confirmLoading={storeItemSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
修改保存后,系统将自动重算明细金额、订货单与采购单汇总。
</div>
<Form form={storeItemForm} layout="vertical" onFinish={handleStoreItemSave}>
<Form.Item
label="单价(元)"
name="price"
rules={[{ required: true, message: '请输入单价' }]}
>
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
</Form.Item>
<Form.Item
label="采购数量"
name="quantity"
rules={[{ required: true, message: '请输入采购数量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入采购数量" />
</Form.Item>
<Form.Item label="称重(斤)" name="weight" rules={[{ required: true, message: '请输入称重' }]}>
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
{/* 商品明细导出:供应商筛选(全部/单个) */}
<Modal
title="导出商品明细"
open={itemExportOpen}
onCancel={() => setItemExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchase(detail.purchase.id!, itemExportSupplier > 0 ? itemExportSupplier : undefined);
}
setItemExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<div className="py-2 text-gray-500">
导出系统全部商品行(无订货的商品数量为 0),门店列附数量合计与突出颜色。
</div>
<div className="flex items-center gap-2 py-2">
<Text>供应商:</Text>
<Select
value={itemExportSupplier}
onChange={setItemExportSupplier}
className="flex-1"
options={[
{ value: 0, label: '全部供应商' },
...purchaseSuppliers.map((s) => ({ value: s.id, label: s.name })),
]}
/>
</div>
</Modal>
{/* 门店购买详情导出:当前门店 / 全部门店(多工作表) */}
<Modal
title="导出门店购买详情"
open={storeExportOpen}
onCancel={() => setStoreExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchaseStores(
detail.purchase.id!,
storeExportScope === 'current' && storeId > 0 ? storeId : undefined,
);
}
setStoreExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<Radio.Group
className="py-2"
value={storeExportScope}
onChange={(e) => setStoreExportScope(e.target.value)}
options={[
{ value: 'current', label: `当前门店(${detail?.stores.find((s) => s.id === storeId)?.name ?? '-'}` },
{ value: 'all', label: '全部门店(合并为一个 XLSX,每门店一个工作表)' },
]}
/>
</Modal>
{/* 供应商采购明细导出:当前供应商 / 全部供应商(多工作表) */}
<Modal
title="导出供应商采购明细"
open={supplierExportOpen}
onCancel={() => setSupplierExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchaseSuppliers(
detail.purchase.id!,
supplierExportScope === 'current' && supplierId > 0 ? supplierId : undefined,
);
}
setSupplierExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<Radio.Group
className="py-2"
value={supplierExportScope}
onChange={(e) => setSupplierExportScope(e.target.value)}
options={[
{ value: 'current', label: `当前供应商(${purchaseSuppliers.find((s) => s.id === supplierId)?.name ?? '-'}` },
{ value: 'all', label: '全部供应商(合并为一个 XLSX,每供应商一个工作表)' },
]}
/>
</Modal>
</>
);
};
export default PurchaseOrderPage;