回筐、账单完成
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user