账单生成

This commit is contained in:
liu
2026-08-14 00:15:28 +08:00
parent 61a70319a0
commit e35c951a59
19 changed files with 728 additions and 631 deletions
+251 -169
View File
@@ -20,7 +20,7 @@ import {
Tag,
Typography,
} from 'antd';
import {DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
import {AccountBookOutlined, DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
@@ -30,9 +30,10 @@ import type {
} from '@/components/XinTable/typings.ts';
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type {
IBill,
IBillPrepare,
IPurchaseCell,
IPurchaseCellItem,
IPurchaseContainerStore,
IPurchaseDetail,
IPurchaseDetailRow,
IPurchaseStoreItem,
@@ -42,12 +43,15 @@ import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
exportPurchase,
generateBill,
getBillPrepare,
getPurchaseCell,
getPurchaseDetail,
getPurchaseStoreSummary,
type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams,
type BillGenerateStoreParams,
type PurchaseCellUpdateParams,
type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseContainer,
updatePurchaseRow,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
@@ -100,25 +104,28 @@ const PurchaseOrderPage: React.FC = () => {
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false);
// 周转/托盘合并记录修改(按门店覆盖全部订单逐笔修改
const [containerOpen, setContainerOpen] = useState(false);
const [containerStore, setContainerStore] = useState<IPurchaseContainerStore | null>(null);
const [containerSaving, setContainerSaving] = useState(false);
const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>();
// 生成账单(已完成采购单按门店生成:填写配送费/周转/托盘数量,金额只读
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 watchContainerOrders = Form.useWatch('orders', containerForm) ?? [];
const previewContainerBox = watchContainerOrders.reduce(
(sum, order) => sum + Number(order?.box_num ?? 0),
0,
);
const previewContainerTray = watchContainerOrders.reduce(
(sum, order) => sum + Number(order?.tray_num ?? 0),
0,
);
const previewContainerAdded =
previewContainerBox * Number(containerStore?.box_price ?? 0) +
previewContainerTray * Number(containerStore?.tray_price ?? 0);
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加
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 ?? []));
@@ -264,34 +271,45 @@ const PurchaseOrderPage: React.FC = () => {
}
};
/** 打开周转/托盘合并记录修改(初始化该门店全部订单的当前数量 */
const openContainerEdit = (store: IPurchaseContainerStore) => {
setContainerStore(store);
containerForm.setFieldsValue({
orders: store.orders.map((order) => ({
order_id: order.order_id,
box_num: order.box_num,
tray_num: order.tray_num,
})),
});
setContainerOpen(true);
/** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转/托盘数量 */
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 handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => {
if (!detail || !containerStore) {
/** 提交生成账单:按门店各生成一张并关联采购单全部订单,金额由系统汇总不可修改 */
const handleBillSave = async (values: { stores: BillGenerateStoreParams[] }) => {
if (!billPrepare) {
return;
}
setContainerSaving(true);
setBillSaving(true);
try {
await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders);
message.success('周转框/托盘已更新,订单金额已重算');
setContainerOpen(false);
setContainerStore(null);
await loadDetail(detail.purchase.id!);
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 {
setContainerSaving(false);
setBillSaving(false);
}
};
@@ -473,56 +491,87 @@ const PurchaseOrderPage: React.FC = () => {
);
};
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */
const containerColumns: TableProps<IPurchaseContainerStore>['columns'] = [
{ title: '门店', dataIndex: 'store_name', width: 180, align: 'center' },
{ title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' },
/** 门店账单列:门店/账单号/账单日期/商品金额/配送费/周转筐/托盘/附加金额/总金额 */
const billColumns: TableProps<IBill>['columns'] = [
{ title: '门店', dataIndex: 'store_name', width: 140, align: 'center' },
{
title: '周转框合计',
dataIndex: 'box_num',
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: (_, row) => (
<Typography.Link onClick={() => openContainerEdit(row)}>{row.box_num}</Typography.Link>
),
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '周转托盘合计',
dataIndex: 'tray_num',
width: 120,
title: '配送费',
dataIndex: 'delivery_fee',
width: 100,
align: 'center',
render: (_, row) => (
<Typography.Link onClick={() => openContainerEdit(row)}>{row.tray_num}</Typography.Link>
),
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: 110,
width: 100,
align: 'center',
render: (_, row) => <Text strong type="danger">¥{row.added_amount}</Text>,
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>,
},
];
/** 周转框/托盘合并记录合计行 */
const renderContainerSummary = () => {
const containers = detail?.containers ?? [];
const totalBox = containers.reduce((sum, row) => sum + row.box_num, 0);
const totalTray = containers.reduce((sum, row) => sum + row.tray_num, 0);
const totalAdded = containers.reduce((sum, row) => sum + Number(row.added_amount), 0);
/** 门店账单合计行 */
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={2} align="center">
<Table.Summary.Cell index={0} colSpan={3} align="center">
<Text strong></Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="center">
<Text strong>{totalBox}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="center">
<Text strong>{totalTray}</Text>
<Text strong>¥{totalProduct.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong type="danger">¥{totalAdded.toFixed(2)}</Text>
<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.Row>
);
@@ -632,7 +681,20 @@ const PurchaseOrderPage: React.FC = () => {
</Button>
</Popconfirm>
</AuthButton>
) : null
) : (
<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> = {
@@ -696,7 +758,7 @@ const PurchaseOrderPage: React.FC = () => {
items={[
{ key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' },
{ key: 'containers', label: '周转框/托盘' },
{ key: 'bills', label: '门店账单' },
]}
/>
@@ -736,21 +798,21 @@ const PurchaseOrderPage: React.FC = () => {
)}
</Spin>
</>
) : detailTab === 'containers' ? (
detail.containers.length > 0 ? (
<Table<IPurchaseContainerStore>
rowKey="store_id"
) : detailTab === 'bills' ? (
detail.bills.length > 0 ? (
<Table<IBill>
rowKey="id"
size="small"
bordered
columns={containerColumns}
dataSource={detail.containers}
columns={billColumns}
dataSource={detail.bills}
pagination={false}
summary={renderContainerSummary}
summary={renderBillSummary}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该采购单暂无门店订单"
description={detail.purchase.status === 0 ? '采购单完成后可在列表操作列生成账单' : '该采购单暂未生成账单'}
className="py-8!"
/>
)
@@ -1005,99 +1067,119 @@ const PurchaseOrderPage: React.FC = () => {
</Form>
</Modal>
{/* 周转/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读 */}
{/* 生成账单:按门店填写配送费/周转/托盘数量(商品金额只读,由订单汇总 */}
<Modal
title={
containerStore
? `${containerStore.store_name} · 周转框/托盘`
: '周转框/托盘'
}
open={containerOpen}
title={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
open={billOpen}
onCancel={() => {
setContainerOpen(false);
setContainerStore(null);
setBillOpen(false);
setBillPrepare(null);
}}
onOk={() => containerForm.submit()}
confirmLoading={containerSaving}
okText="保存"
okButtonProps={{ disabled: detail?.purchase.status !== 0 }}
width={760}
onOk={() => billForm.submit()}
confirmLoading={billSaving}
okText="确认生成"
okButtonProps={{ disabled: billAllGenerated }}
width={960}
destroyOnHidden
>
<div className="py-2 text-gray-500">
{containerStore?.order_count ?? 0}
{detail?.purchase.status === 0
? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。'
: '采购单已完成,仅可查看。'}
</div>
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
<Form.List name="orders">
{(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="flex-1"></div>
<div className="w-36 shrink-0 text-center">
¥{containerStore?.box_price ?? '0.00'}
</div>
<div className="w-36 shrink-0 text-center">
¥{containerStore?.tray_price ?? '0.00'}
</div>
</div>
{fields.map((field) => {
const order = containerStore?.orders[field.name];
return (
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
<div className="min-w-0 flex-1 pr-2 text-sm">
<Text copyable={{ text: order?.order_no ?? '' }}>{order?.order_no ?? '-'}</Text>
</div>
<Form.Item name={[field.name, 'order_id']} hidden>
<Input />
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0"
name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转框数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={detail?.purchase.status !== 0}
placeholder="周转框数量"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0"
name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={detail?.purchase.status !== 0}
placeholder="周转托盘数量"
/>
</Form.Item>
</div>
);
})}
<Spin spinning={billLoading}>
{billPrepare && (
<>
<div className="py-2 text-gray-500">
/
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div>
)}
</Form.List>
</Form>
<Space orientation="vertical" className="mt-3! w-full rounded bg-gray-50 p-3">
<div>
<Text type="secondary"></Text>
<Text strong>{previewContainerBox}</Text>
<Text type="secondary" className="ml-6!"></Text>
<Text strong>{previewContainerTray}</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text strong type="danger">¥{previewContainerAdded.toFixed(2)}</Text>
</div>
</Space>
<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"
min={0}
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"
min={0}
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>
</>
);