Files
xin-procurement/web/pages/recon/bill.tsx
T
2026-08-14 01:19:47 +08:00

423 lines
14 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, useRef, useState } from 'react';
import {
Button,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
message,
Modal,
Table,
Tag,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { UnorderedListOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type { IBill, IBillDetail, IBillGoodsItem, IBillOrder } from '@/domain/iBill.ts';
import { BILL_STATUS_MAP } from '@/domain/iBill.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import { getBillDetail, payBill } from '@/api/recon/bill.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 确认收款表单 */
interface PayFormValues {
paid_at: dayjs.Dayjs;
pay_remark?: string;
}
/**
* 门店账单(采购单完成后按门店生成;详情含合并后的商品明细与关联订单;线下收款手动登记)
*/
const BillPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IBill>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IBillDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// 确认收款(线下收款手动登记)
const [payTarget, setPayTarget] = useState<IBill | null>(null);
const [paySaving, setPaySaving] = useState(false);
const [payForm] = Form.useForm<PayFormValues>();
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
try {
const res = await getBillDetail(id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
/** 打开确认收款弹窗(默认付款时间为当前) */
const openPay = (record: IBill) => {
setPayTarget(record);
payForm.setFieldsValue({ paid_at: dayjs(), pay_remark: '' });
};
/** 提交确认收款:登记付款信息并置为已支付 */
const handlePaySave = async (values: PayFormValues) => {
if (!payTarget?.id) {
return;
}
setPaySaving(true);
try {
await payBill(payTarget.id, {
paid_at: values.paid_at.format('YYYY-MM-DD HH:mm:ss'),
pay_remark: values.pay_remark ?? '',
});
message.success('收款已登记,账单已置为已支付');
setPayTarget(null);
await tableRef.current?.reload();
} finally {
setPaySaving(false);
}
};
/** 合并商品明细列:品名/包规/单位/单价(加权平均)/数量/重量/金额 */
const itemColumns: TableProps<IBillGoodsItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name', width: 160, align: 'center' },
{ title: '包规', dataIndex: 'product_spec', width: 100, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 80, 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: 100, align: 'center', render: (v) => `${v}斤` },
{
title: '金额',
dataIndex: 'amount',
width: 110,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
];
/** 合并商品明细合计行 */
const renderItemSummary = () => {
const items = detail?.items ?? [];
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={4} align="center">
<Text strong>合计</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 关联订单列 */
const orderColumns: TableProps<IBillOrder>['columns'] = [
{
title: '订单号',
dataIndex: 'order_no',
align: 'center',
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
},
{ title: '订货日期', dataIndex: 'order_date', align: 'center' },
{ title: '订货数量', dataIndex: 'total_quantity', align: 'center' },
{ title: '总重量', dataIndex: 'total_weight', align: 'center', render: (v) => `${v}斤` },
{
title: '订单金额',
dataIndex: 'total_amount',
align: 'center',
render: (v) => <Text strong>¥{v}</Text>,
},
{
title: '状态',
dataIndex: 'status',
align: 'center',
render: (v) => {
const item = STORE_ORDER_STATUS_MAP[Number(v ?? 0)];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
];
const columns: XinTableColumn<IBill>[] = [
{
title: '账单号',
dataIndex: 'bill_no',
valueType: 'text',
hideInForm: true,
width: 210,
render: (_, record) => <Text copyable={{ text: record.bill_no }}>{record.bill_no}</Text>,
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) => record.store?.name ?? `门店#${record.store_id}`,
},
{
title: '采购单号',
dataIndex: 'purchase_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => record.purchase?.purchase_no ?? '-',
},
{
title: '账单日期',
dataIndex: 'bill_date',
valueType: 'dateRange',
hideInForm: true,
align: 'center',
render: (_, record) => record.bill_date,
},
{
title: '商品金额',
dataIndex: 'product_amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => ${record.product_amount}`,
},
{
title: '配送费',
dataIndex: 'delivery_fee',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => ${record.delivery_fee}`,
},
{
title: '附加金额',
dataIndex: 'added_amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => (
<span title={`周转筐 ${record.box_num} 个 / 周转托盘 ${record.tray_num} 个`}>
¥{record.added_amount}
</span>
),
},
{
title: '账单总金额',
dataIndex: 'total_amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => <Text strong type="danger">¥{record.total_amount}</Text>,
},
{
title: '支付状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
align: 'center',
fieldProps: {
options: Object.entries(BILL_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = BILL_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{
title: '关联订单',
dataIndex: 'orders_count',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => `${record.orders_count ?? 0} 笔`,
},
{
title: '生成时间',
dataIndex: 'created_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
];
const operateRender: XinTableProps<IBill>['operateRender'] = (record) => [
<Button
key="detail"
size="small"
type="primary"
icon={<UnorderedListOutlined />}
onClick={() => openDetail(record.id!)}
/>,
record.status === 0 ? (
<AuthButton key="pay" auth="recon.bill.pay">
<Button size="small" variant="solid" color="green" onClick={() => openPay(record)}>
确认收款
</Button>
</AuthButton>
) : null,
];
const tableProps: XinTableProps<IBill> = {
api: '/recon/bill',
columns,
rowKey: 'id',
accessName: 'recon.bill',
tableRef,
operateRender,
formProps: false,
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
};
return (
<>
<div className="mb-5">
<Title level={3}>门店账单</Title>
<Text type="secondary">
采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘)。
</Text>
</div>
<XinTable<IBill> {...tableProps} />
<Drawer
title={detail ? `账单 ${detail.bill.bill_no}` : '账单详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
size={1000}
loading={detailLoading}
>
{detail ? (
<>
<Descriptions column={3} size="small" bordered>
<Descriptions.Item label="门店">{detail.bill.store?.name ?? `门店#${detail.bill.store_id}`}</Descriptions.Item>
<Descriptions.Item label="采购单号">{detail.bill.purchase?.purchase_no ?? '-'}</Descriptions.Item>
<Descriptions.Item label="账单日期">{detail.bill.bill_date}</Descriptions.Item>
<Descriptions.Item label="商品金额">¥{detail.bill.product_amount}</Descriptions.Item>
<Descriptions.Item label="配送费">¥{detail.bill.delivery_fee}</Descriptions.Item>
<Descriptions.Item label="附加金额">
¥{detail.bill.added_amount}
<Text type="secondary" className="ml-2!">
(筐 {detail.bill.box_num}×¥{detail.bill.box_price},托盘 {detail.bill.tray_num}×¥{detail.bill.tray_price}
</Text>
</Descriptions.Item>
<Descriptions.Item label="账单总金额">
<Text strong type="danger">¥{detail.bill.total_amount}</Text>
</Descriptions.Item>
<Descriptions.Item label="支付状态">
<Tag color={BILL_STATUS_MAP[detail.bill.status ?? 0]?.color}>
{BILL_STATUS_MAP[detail.bill.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="付款时间">{detail.bill.paid_at ?? '-'}</Descriptions.Item>
<Descriptions.Item label="收款人">{detail.bill.paid_operator?.nickname ?? '-'}</Descriptions.Item>
<Descriptions.Item label="生成人">{detail.bill.operator?.nickname ?? '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间">{detail.bill.created_at}</Descriptions.Item>
{detail.bill.pay_remark ? (
<Descriptions.Item label="付款备注" span={3}>{detail.bill.pay_remark}</Descriptions.Item>
) : null}
{detail.bill.remark ? (
<Descriptions.Item label="备注" span={3}>{detail.bill.remark}</Descriptions.Item>
) : null}
</Descriptions>
<Title level={5} className="mt-6! mb-3!">
商品明细(按商品合并)
</Title>
<Table<IBillGoodsItem>
rowKey="product_id"
size="small"
bordered
columns={itemColumns}
dataSource={detail.items}
pagination={false}
summary={renderItemSummary}
/>
<Title level={5} className="mt-6! mb-3!">
关联订单({detail.orders.length} 笔)
</Title>
<Table<IBillOrder>
rowKey="id"
size="small"
bordered
columns={orderColumns}
dataSource={detail.orders}
pagination={false}
/>
</>
) : null}
</Drawer>
{/* 确认收款:线下收款后手动登记付款信息 */}
<Modal
title={payTarget ? `确认收款 · ${payTarget.bill_no}` : '确认收款'}
open={payTarget !== null}
onCancel={() => setPayTarget(null)}
onOk={() => payForm.submit()}
confirmLoading={paySaving}
okText="确认收款"
destroyOnHidden
>
<div className="py-2 text-gray-500">
应收金额 <Text strong type="danger">¥{payTarget?.total_amount ?? '0.00'}</Text>
(商品 ¥{payTarget?.product_amount ?? '0.00'} + 配送费 ¥{payTarget?.delivery_fee ?? '0.00'} + 附加 ¥{payTarget?.added_amount ?? '0.00'});
线下收款完成后登记付款信息,账单支付状态将置为「已支付」。
</div>
<Form form={payForm} layout="vertical" onFinish={handlePaySave}>
<Form.Item
label="付款时间"
name="paid_at"
rules={[{ required: true, message: '请选择付款时间' }]}
>
<DatePicker className="w-full" showTime allowClear={false} />
</Form.Item>
<Form.Item label="付款备注" name="pay_remark" rules={[{ max: 255 }]}>
<Input.TextArea rows={2} maxLength={255} placeholder="如:现金/转账单号等线下收款信息(选填)" />
</Form.Item>
</Form>
</Modal>
</>
);
};
export default BillPage;