Files
xin-procurement/web/pages/purchase/order.tsx
T
2026-09-06 22:34:25 +08:00

1606 lines
60 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,
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,
IPurchaseDetail,
IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
IPurchaseSupplierItem,
PurchaseStoreItemAddParams,
PurchaseStoreItemUpdateParams,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import {
addPurchaseStoreItem,
exportPurchase,
exportPurchaseStores,
exportPurchaseSuppliers,
generateBill,
getBillPrepare,
getPurchaseDetail,
getPurchaseStoreSummary,
removePurchaseStoreItem,
type BillGenerateStoreParams,
type PurchaseRowUpdateParams,
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';
import useAuth from '@/hooks/useAuth.ts';
const { Title, Text } = Typography;
/** 商品明细可行内编辑的字段 */
type InlineEditField = 'product_name' | 'supplier_id' | 'product_spec' | 'unit' | 'cost_price';
/** 行内编辑字段中文名(校验提示用) */
const INLINE_FIELD_LABELS: Record<InlineEditField, string> = {
product_name: '品名',
supplier_id: '供应商',
product_spec: '包规',
unit: '单位',
cost_price: '成本',
};
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 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 { auth } = useAuth();
// 详情抽屉
const [detailOpen, setDetailOpen] = useState(false);
const [detailSize, setDetailSize] = useState(1200);
const [detail, setDetail] = useState<IPurchaseDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [completing, setCompleting] = useState(false);
// 行内编辑:供应商选项(品名/供应商/包规/单位/成本与各门店订货量直接在表格中编辑)
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 门店购买详情(按商品聚合的门店采购汇总)
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 [marketFilter, setMarketFilter] = useState<string>('');
// 商品明细页签:供应商/市场列筛选值(antd Table 受控筛选,用于底部统计行联动)
const [itemColumnFilters, setItemColumnFilters] = useState<Record<string, (React.Key | boolean)[] | null>>({});
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
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 supplierMarkets = useMemo(() => {
const markets = new Set<string>();
(detail?.items ?? []).forEach((row) => {
if (row.supplier_id === supplierId && row.market) {
markets.add(row.market);
}
});
return Array.from(markets);
}, [detail?.items, supplierId]);
/** 供应商采购明细行:按供应商+市场过滤矩阵行,金额=数量×成本价(成本口径) */
const supplierItems = useMemo<IPurchaseSupplierItem[]>(() => {
if (!detail || supplierId <= 0) return [];
return detail.items
.filter((row) => row.supplier_id === supplierId)
.filter((row) => marketFilter === '' || (row.market ?? '') === marketFilter)
.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, marketFilter]);
/** 商品明细市场列筛选选项(采购单内出现的全部市场) */
const itemMarketOptions = useMemo(() => {
const markets = new Set<string>();
(detail?.items ?? []).forEach((row) => {
if (row.market) {
markets.add(row.market);
}
});
return Array.from(markets);
}, [detail?.items]);
/** 商品明细当前可见行:按列筛选值本地过滤,驱动底部成本统计与列筛选联动 */
const summaryItems = useMemo<IPurchaseDetailRow[]>(() => {
const supplierKeys = itemColumnFilters.supplier;
const marketKeys = itemColumnFilters.market;
return (detail?.items ?? [])
.filter((row) => !supplierKeys?.length || supplierKeys.includes(row.supplier_id))
.filter((row) => !marketKeys?.length || marketKeys.includes(row.market ?? ''));
}, [detail?.items, itemColumnFilters]);
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
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 afterSale = Number(input.after_sale ?? 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 + afterSale,
};
});
const billAllGenerated =
(billPrepare?.stores ?? []).length > 0 && billPrepare!.stores.every((row) => row.bill !== null);
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
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);
setItemColumnFilters({});
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 canUpdateRow = detail?.purchase.status === 0 && auth('purchase.order.update');
/**
* 行内编辑保存:合并该行当前值整行提交(后端要求全字段);未变更 / 校验失败时不请求。
* Enter 或失焦触发保存,保存后刷新详情与列表。
*/
const handleInlineSave = async (row: IPurchaseDetailRow, dataIndex: InlineEditField, rawValue: string | number) => {
if (!detail) {
return;
}
const params: PurchaseRowUpdateParams = {
product_name: row.product_name,
supplier_id: row.supplier_id,
product_spec: row.product_spec,
unit: row.unit,
cost_price: Number(row.cost_price),
};
if (dataIndex === 'cost_price') {
const cost = Number(rawValue);
if (rawValue === '' || !Number.isFinite(cost) || cost < 0) {
message.warning('成本无效,已取消修改');
return;
}
if (cost === Number(row.cost_price)) {
return;
}
params.cost_price = cost;
} else if (dataIndex === 'supplier_id') {
const supplierId = Number(rawValue);
if (supplierId === row.supplier_id) {
return;
}
params.supplier_id = supplierId;
} else {
const text = String(rawValue).trim();
if (!text) {
message.warning(`${INLINE_FIELD_LABELS[dataIndex]}不能为空,已取消修改`);
return;
}
if (text === row[dataIndex]) {
return;
}
params[dataIndex] = text;
}
if (params.supplier_id <= 0) {
message.warning('请先通过供应商单元格为该商品设置供应商');
return;
}
const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params);
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
const res1 = await getPurchaseDetail(detail.purchase.id!);
setDetail(res1.data.data ?? null);
await tableRef.current?.reload();
};
/**
* 门店订货量保存:复用门店单品修改接口(仅提交订货量,单价/称重不改动),
* 同商品多笔订单明细合并到最早一条并级联重算;未变更 / 校验失败时不请求。
*/
const handleStoreCellSave = async (row: IPurchaseDetailRow, storeId: number, rawValue: string) => {
if (!detail) {
return;
}
const quantity = Number(rawValue);
if (rawValue.trim() === '' || !Number.isInteger(quantity) || quantity < 0) {
message.warning('订货量无效,已取消修改');
return;
}
if (quantity === row.cells[storeId]) {
return;
}
await updatePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id, { quantity });
message.success('订货量已更新,订货单与采购单汇总已重算');
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
};
/** 行内编辑单元格:直接渲染编辑组件(文本 Input / 供应商 Select / 成本 InputNumber),Enter 或失焦保存 */
const renderEditableCell = (
row: IPurchaseDetailRow,
dataIndex: InlineEditField,
display: React.ReactNode,
): React.ReactNode => {
if (!canUpdateRow) {
return display;
}
if (dataIndex === 'supplier_id') {
return (
<Select
size="small"
className="w-full"
value={row.supplier_id > 0 ? row.supplier_id : undefined}
placeholder="选择供应商"
showSearch={{ optionFilterProp: 'label' }}
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
onChange={(value) => void handleInlineSave(row, dataIndex, value)}
/>
);
}
if (dataIndex === 'cost_price') {
return (
<InputNumber
key={`${row.product_id}-cost-${row.cost_price}`}
size="small"
min={0}
precision={2}
prefix="¥"
className="w-full"
defaultValue={Number(row.cost_price)}
onPressEnter={(e) => void handleInlineSave(row, dataIndex, (e.target as HTMLInputElement).value)}
onBlur={(e) => void handleInlineSave(row, dataIndex, e.target.value)}
/>
);
}
return (
<Input
key={`${row.product_id}-${dataIndex}-${row[dataIndex]}`}
size="small"
style={{ textAlign: 'center' }}
defaultValue={row[dataIndex]}
maxLength={dataIndex === 'unit' ? 20 : 100}
onPressEnter={(e) => void handleInlineSave(row, dataIndex, (e.target as HTMLInputElement).value)}
onBlur={(e) => void handleInlineSave(row, dataIndex, e.target.value)}
/>
);
};
/** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转筐/托盘数量 */
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,
after_sale: row.bill ? Number(row.bill.after_sale) : 0,
remark: row.bill?.remark ?? '',
})),
});
} 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: 160,
align: 'center',
fixed: 'left',
ellipsis: true,
render: (_, row) => renderEditableCell(row, 'product_name', row.product_name),
},
{
title: '供应商',
dataIndex: 'supplier',
width: 110,
align: 'center',
fixed: 'left',
filters: purchaseSuppliers.map((s) => ({ text: s.name, value: s.id })),
filteredValue: itemColumnFilters.supplier ?? null,
onFilter: (value, row) => row.supplier_id === Number(value),
render: (_, row) => renderEditableCell(row, 'supplier_id', row.supplier?.name ?? '-'),
},
{
title: '市场',
fixed: 'left',
dataIndex: 'market',
width: 110,
align: 'center',
filters: itemMarketOptions.map((m) => ({ text: m, value: m })),
filteredValue: itemColumnFilters.market ?? null,
onFilter: (value, row) => (row.market ?? '') === value,
render: (v) => v || '-',
},
{
title: '单价',
key: 'retail_price',
width: 110,
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: 110,
render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'),
},
{
title: '单位',
dataIndex: 'unit',
width: 110,
align: 'center',
render: (v, row) => renderEditableCell(row, 'unit', v || '-'),
},
{
title: '成本',
dataIndex: 'cost_price',
width: 110,
align: 'center',
render: (v, row) => renderEditableCell(row, 'cost_price', ${Number(v).toFixed(2)}`),
},
{
title: '合计数量',
key: 'total_quantity',
width: 110,
align: 'center',
render: (_, row) => <Text strong>{row.quantity}</Text>,
},
];
const storeColumns = (detail?.stores ?? []).map((store) => ({
title: <span className={'text-[red]'}>{store.name}</span>,
key: `store-${store.id}`,
align: 'center' as const,
render: (_: unknown, row: IPurchaseDetailRow) => {
const quantity = row.cells[store.id];
if (quantity === undefined) {
return <Text type="secondary">-</Text>;
}
if (!canUpdateRow) {
return quantity;
}
// 订货量直接在单元格编辑:Enter 或失焦保存(单价/称重不改动)
return (
<InputNumber
key={`${row.product_id}-${store.id}-${quantity}`}
size="small"
min={0}
precision={0}
className="w-full"
defaultValue={quantity}
onPressEnter={(e) => void handleStoreCellSave(row, store.id, (e.target as HTMLInputElement).value)}
onBlur={(e) => void handleStoreCellSave(row, store.id, e.target.value)}
/>
);
},
}));
return [...fixed, ...storeColumns];
};
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计(随供应商/市场列筛选联动) */
const renderSummary = () => {
const stores = detail?.stores ?? [];
const items = summaryItems;
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">
<Space size={16}>
<Text strong>成本统计(按门店)</Text>
<Text strong type="danger">总计 ¥{totalAmount.toFixed(2)}</Text>
</Space>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
{storeTotals.map((amount, index) => (
<Table.Summary.Cell key={stores[index].id} index={8 + index} align="center">
<Text strong>¥{amount.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={detailSize}
resizable={{
onResize: (newSize) => setDetailSize(newSize),
}}
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);
setMarketFilter('');
}}
placeholder="选择供应商"
className="w-60!"
showSearch
optionFilterProp="label"
options={purchaseSuppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
<Text>市场:</Text>
<Select
value={marketFilter}
onChange={setMarketFilter}
className="w-40!"
options={[
{ value: '', label: '全部市场' },
...supplierMarkets.map((m) => ({ value: m, label: m })),
]}
/>
</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>
{canUpdateRow && (
<Text type="secondary" className="text-xs">
修改保存将同步该商品全部订货明细与商品档案,成本变化时各门店单价按等级上浮自动重算
</Text>
)}
</Space>
<Table<IPurchaseDetailRow>
rowKey="product_id"
size="small"
bordered
columns={buildItemColumns()}
dataSource={detail.items}
onChange={(_pagination, filters) => setItemColumnFilters(filters)}
pagination={false}
scroll={{ x: 1200, y: 800 }}
summary={renderSummary}
/>
</>
) }
</>
)}
</Drawer>
{/* 生成账单:按门店填写配送费/周转筐/托盘数量与售后金额(商品金额只读,由订单汇总) */}
<Modal
title={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
open={billOpen}
onCancel={() => {
setBillOpen(false);
setBillPrepare(null);
}}
onOk={() => billForm.submit()}
confirmLoading={billSaving}
okText="确认生成"
okButtonProps={{ disabled: billAllGenerated }}
width={1280}
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-36 shrink-0 text-center">售后金额(元)</div>
<div className="w-36 shrink-0 text-center">备注</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>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'after_sale']}
>
<InputNumber
className="w-full"
precision={2}
disabled={billed}
placeholder="可正负,计入总额"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'remark']}
>
<Input
maxLength={255}
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
>
<div className="py-2 text-gray-500">
导出 XLSX 按「供应商 × 市场」拆分工作表,列序:品名、汇总、市场、各门店明细,有数据的单元格突出显示。
</div>
<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;