Files
xin-procurement/web/pages/recon/list.tsx
T
2026-07-23 20:41:25 +08:00

720 lines
21 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,
Descriptions,
Drawer,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import {
CheckSquareOutlined,
FileDoneOutlined,
ToolOutlined,
} 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 IReconciliation from '@/domain/iReconciliation.ts';
import type { IReconDiff, IReconciliationItem } from '@/domain/iReconciliation.ts';
import { RECON_STATUS_MAP } from '@/domain/iReconciliation.ts';
import type IProductCategory from '@/domain/iProductCategory.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getCategoryTree } from '@/api/product/category.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import {
buildRecon,
getReconDiff,
remarkReconItem,
settleRecon,
toggleReconItem,
updateReconItem,
} from '@/api/recon/list.ts';
import { Get } from '@/api/common/table.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
interface EditingItem {
product_name: string;
quantity: number;
weight: number;
publish_amount: number;
actual_amount: number;
}
/**
* 财务对账(D1/D2 筛选建单、D4 明细修改、D5 差额对比、D6 备注、D8 标记、D9 结算)
*/
const ReconListPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IReconciliation>>(null);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 工作台抽屉
const [workOpen, setWorkOpen] = useState(false);
const [workLoading, setWorkLoading] = useState(false);
const [recon, setRecon] = useState<IReconciliation | null>(null);
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
const [savingItemId, setSavingItemId] = useState<number | null>(null);
const [diff, setDiff] = useState<IReconDiff | null>(null);
// 备注弹窗
const [remarkOpen, setRemarkOpen] = useState(false);
const [remarkTarget, setRemarkTarget] = useState<IReconciliationItem | null>(null);
const [remarkValue, setRemarkValue] = useState('');
useEffect(() => {
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
const loadRecon = async (id: number) => {
const res = await Get<IReconciliation>('/recon/list', id);
const data = res.data.data ?? null;
setRecon(data);
const editingMap: Record<number, EditingItem> = {};
data?.items?.forEach((item) => {
if (item.id !== undefined) {
editingMap[item.id] = {
product_name: item.product_name ?? '',
quantity: Number(item.quantity ?? 0),
weight: Number(item.weight ?? 0),
publish_amount: Number(item.publish_amount ?? 0),
actual_amount: Number(item.actual_amount ?? 0),
};
}
});
setEditing(editingMap);
return data;
};
const openWorkbench = async (id: number) => {
setWorkOpen(true);
setWorkLoading(true);
setDiff(null);
try {
await loadRecon(id);
} finally {
setWorkLoading(false);
}
};
const loadDiff = async (id: number) => {
const res = await getReconDiff(id);
setDiff(res.data.data ?? null);
};
const handleBuild = async (record: IReconciliation) => {
const res = await buildRecon(record.id!);
message.success(`已生成 ${res.data.data?.count} 条对账明细`);
await tableRef.current?.reload();
};
const handleSettle = async (record: IReconciliation) => {
const res = await settleRecon(record.id!);
message.success(`已生成 ${res.data.data?.count} 张结算表`);
await tableRef.current?.reload();
};
const isItemDirty = (item: IReconciliationItem): boolean => {
const edit = editing[item.id!];
if (!edit) {
return false;
}
return (
edit.product_name !== (item.product_name ?? '') ||
edit.quantity !== Number(item.quantity ?? 0) ||
edit.weight !== Number(item.weight ?? 0) ||
edit.publish_amount !== Number(item.publish_amount ?? 0) ||
edit.actual_amount !== Number(item.actual_amount ?? 0)
);
};
const saveItem = async (item: IReconciliationItem) => {
const edit = editing[item.id!];
if (!edit || !isItemDirty(item)) {
return;
}
setSavingItemId(item.id!);
try {
const res = await updateReconItem(item.id!, {
product_name: edit.product_name,
quantity: edit.quantity,
weight: edit.weight,
publish_amount: edit.publish_amount,
actual_amount: edit.actual_amount,
});
message.success(`已保存,差额 ¥${res.data.data?.diff_amount}`);
await loadRecon(recon!.id!);
await loadDiff(recon!.id!);
} finally {
setSavingItemId(null);
}
};
const handleToggle = async (item: IReconciliationItem) => {
await toggleReconItem(item.id!);
await loadRecon(recon!.id!);
};
const openRemark = (item: IReconciliationItem) => {
setRemarkTarget(item);
setRemarkValue(item.store_remark ?? '');
setRemarkOpen(true);
};
const saveRemark = async () => {
await remarkReconItem(remarkTarget!.id!, remarkValue);
message.success('备注已保存');
setRemarkOpen(false);
await loadRecon(recon!.id!);
};
const readonly = recon?.status === 2;
const itemColumns: TableProps<IReconciliationItem>['columns'] = [
{
title: '品名',
dataIndex: 'product_name',
width: 160,
render: (_, record) =>
readonly ? (
record.product_name
) : (
<Input
size="small"
value={editing[record.id!]?.product_name}
onChange={(e) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], product_name: e.target.value },
}))
}
/>
),
},
{
title: '门店',
dataIndex: 'store',
width: 130,
render: (_, record) => record.store?.name ?? `门店#${record.store_id}`,
},
{
title: '订货量',
dataIndex: 'quantity',
width: 110,
render: (_, record) =>
readonly ? (
record.quantity
) : (
<InputNumber
size="small"
min={0}
precision={2}
value={editing[record.id!]?.quantity}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], quantity: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '称重',
dataIndex: 'weight',
width: 110,
render: (_, record) =>
readonly ? (
record.weight
) : (
<InputNumber
size="small"
min={0}
precision={3}
value={editing[record.id!]?.weight}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], weight: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '公布金额',
dataIndex: 'publish_amount',
width: 120,
render: (_, record) =>
readonly ? (
${record.publish_amount}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.publish_amount}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], publish_amount: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '实际金额',
dataIndex: 'actual_amount',
width: 120,
render: (_, record) =>
readonly ? (
${record.actual_amount}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.actual_amount}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], actual_amount: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '差额',
dataIndex: 'diff_amount',
width: 100,
align: 'right',
render: (v) => {
const num = Number(v ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{String(v)}
</Text>
);
},
},
{
title: '对账',
dataIndex: 'is_reconciled',
width: 80,
align: 'center',
render: (_, record) => (
<Switch
size="small"
disabled={readonly}
checked={record.is_reconciled === 1}
onChange={() => handleToggle(record)}
/>
),
},
{
title: '门店备注',
dataIndex: 'store_remark',
width: 120,
ellipsis: true,
render: (_, record) =>
record.store_remark || <Text type="secondary"></Text>,
},
{
title: '操作',
key: 'action',
width: 130,
fixed: 'right',
render: (_, record) =>
readonly ? null : (
<Space size={4}>
<AuthButton auth="recon.item.item.update">
<Button
size="small"
type="link"
disabled={!isItemDirty(record)}
loading={savingItemId === record.id}
onClick={() => saveItem(record)}
>
保存
</Button>
</AuthButton>
<AuthButton auth="recon.item.item.update">
<Button size="small" type="link" onClick={() => openRemark(record)}>
备注
</Button>
</AuthButton>
</Space>
),
},
];
const diffColumns = (nameTitle: string, nameKey: 'store_name' | 'product_name') => [
{ title: nameTitle, dataIndex: nameKey, render: (v: string) => v || '-' },
{ title: '公布金额', dataIndex: 'publish', align: 'right' as const, render: (v: number) => ${v}` },
{ title: '实际金额', dataIndex: 'actual', align: 'right' as const, render: (v: number) => ${v}` },
{
title: '差额',
dataIndex: 'diff',
align: 'right' as const,
render: (v: number) => (
<Text type={v === 0 ? 'secondary' : 'danger'} strong={v !== 0}>
¥{v}
</Text>
),
},
];
const columns: XinTableColumn<IReconciliation>[] = [
{
title: '对账单号',
dataIndex: 'recon_no',
valueType: 'text',
hideInForm: true,
},
{
title: '标题',
dataIndex: 'title',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入对账标题' }],
},
{
title: '对账周期',
dataIndex: 'period',
hideInForm: true,
hideInSearch: true,
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
},
{
title: '开始日期',
dataIndex: 'period_start',
valueType: 'date',
hideInTable: true,
required: true,
rules: [{ required: true, message: '请选择开始日期' }],
},
{
title: '结束日期',
dataIndex: 'period_end',
valueType: 'date',
hideInTable: true,
required: true,
rules: [{ required: true, message: '请选择结束日期' }],
},
{
title: '商品分类',
dataIndex: 'category_id',
valueType: 'treeSelect',
hideInTable: true,
initialValue: 0,
fieldProps: {
treeData: [{ id: 0, name: '全部分类', children: categoryTree }],
fieldNames: { label: 'name', value: 'id', children: 'children' },
treeDefaultExpandAll: true,
},
},
{
title: '供应商',
dataIndex: 'supplier_id',
valueType: 'select',
hideInTable: true,
initialValue: 0,
fieldProps: {
options: [
{ label: '全部供应商', value: 0 },
...suppliers.map((s) => ({ label: s.name, value: s.id })),
],
},
},
{
title: '公布金额',
dataIndex: 'publish_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => ${record.publish_amount}`,
},
{
title: '实际金额',
dataIndex: 'actual_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => ${record.actual_amount}`,
},
{
title: '差额',
dataIndex: 'diff_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => {
const num = Number(record.diff_amount ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{record.diff_amount}
</Text>
);
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(RECON_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = RECON_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInTable: true,
hideInSearch: true,
fieldProps: { rows: 2 },
},
];
const operateRender: XinTableProps<IReconciliation>['operateRender'] = (record, dom) => [
<AuthButton key="build" auth="recon.list.build">
<Popconfirm
title="生成对账明细?"
description="按周期/品类/供应商拉取已分摊的采购数据,重复生成会清空现有明细。"
disabled={record.status === 2}
onConfirm={() => handleBuild(record)}
>
<Button size="small" disabled={record.status === 2}>
生成明细
</Button>
</Popconfirm>
</AuthButton>,
<Button
key="workbench"
size="small"
type="primary"
ghost
icon={<ToolOutlined />}
disabled={record.status === 0}
onClick={() => openWorkbench(record.id!)}
>
工作台
</Button>,
<AuthButton key="settle" auth="recon.list.settle">
<Popconfirm
title="生成结算表?"
description="按门店聚合对账明细生成结算表,对账单将变为已结算且不可再修改。"
disabled={record.status !== 1}
onConfirm={() => handleSettle(record)}
>
<Button
size="small"
type="primary"
icon={<FileDoneOutlined />}
disabled={record.status !== 1}
>
生成结算表
</Button>
</Popconfirm>
</AuthButton>,
// 编辑/删除由 XinTable 默认提供;删除仅草稿可用,由后端校验拦截
dom.edit,
dom.del,
];
const tableProps: XinTableProps<IReconciliation> = {
api: '/recon/list',
columns,
rowKey: 'id',
accessName: 'recon.list',
tableRef,
operateRender,
scroll: { x: 1300 },
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 720 },
};
return (
<>
<div className="mb-5">
<Title level={3}>财务对账</Title>
<Text type="secondary">
按周期/品类/供应商建立对账单 生成明细(采购分摊数据)→ 核对修改 差额对比 生成结算表。
</Text>
</div>
<XinTable<IReconciliation> {...tableProps} />
{/* 对账工作台 */}
<Drawer
title={recon ? `对账工作台 · ${recon.recon_no}` : '对账工作台'}
open={workOpen}
onClose={() => setWorkOpen(false)}
width={1200}
loading={workLoading}
>
{recon ? (
<>
<Descriptions column={4} size="small" bordered>
<Descriptions.Item label="标题">{recon.title}</Descriptions.Item>
<Descriptions.Item label="周期">
{recon.period_start} ~ {recon.period_end}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={RECON_STATUS_MAP[recon.status ?? 0]?.color}>
{RECON_STATUS_MAP[recon.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="差额">
<Text
type={Number(recon.diff_amount) === 0 ? 'secondary' : 'danger'}
strong
>
¥{recon.diff_amount}
</Text>
</Descriptions.Item>
</Descriptions>
<Tabs
className="mt-4"
items={[
{
key: 'items',
label: (
<span>
<CheckSquareOutlined /> 明细核对({recon.items?.length ?? 0}
</span>
),
children: (
<>
{!readonly ? (
<div className="mb-2 text-gray-500">
可修改订货量/称重/公布金额/实际金额,保存后自动重算差额与对账单汇总;开关标记单品对账状态。
</div>
) : null}
<Table<IReconciliationItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={recon.items ?? []}
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 1250 }}
/>
</>
),
},
{
key: 'diff',
label: '差额对比',
children: (
<>
<Space className="mb-3">
<Button onClick={() => loadDiff(recon.id!)}>刷新对比数据</Button>
{diff ? (
<Text type="secondary">
合计:公布 ¥{diff.total.publish} / 实际 ¥{diff.total.actual} /{' '}
<Text type={diff.total.diff === 0 ? 'secondary' : 'danger'} strong>
差额 ¥{diff.total.diff}
</Text>
</Text>
) : null}
</Space>
{diff ? (
<div className="grid grid-cols-2 gap-4">
<div>
<Title level={5}>按门店</Title>
<Table
rowKey={(row) => String(row.store_id)}
size="small"
columns={diffColumns('门店', 'store_name')}
dataSource={diff.by_store}
pagination={false}
/>
</div>
<div>
<Title level={5}>按商品</Title>
<Table
rowKey={(row) => String(row.product_id)}
size="small"
columns={diffColumns('商品', 'product_name')}
dataSource={diff.by_product}
pagination={false}
/>
</div>
</div>
) : (
<Button type="primary" onClick={() => loadDiff(recon.id!)}>
加载差额对比
</Button>
)}
</>
),
},
]}
/>
</>
) : null}
</Drawer>
{/* 门店备注弹窗 */}
<Modal
title="单品门店备注"
open={remarkOpen}
onCancel={() => setRemarkOpen(false)}
onOk={saveRemark}
okText="保存备注"
destroyOnHidden
>
<div className="mb-2 text-gray-500">
{remarkTarget?.product_name}
{remarkTarget?.store ? ` · ${remarkTarget.store.name}` : ''}
</div>
<Input.TextArea
rows={3}
maxLength={255}
showCount
value={remarkValue}
onChange={(e) => setRemarkValue(e.target.value)}
placeholder="填写该单品针对该门店的备注(如质量异常、补货说明等)"
/>
</Modal>
</>
);
};
export default ReconListPage;