移除对账
This commit is contained in:
@@ -1,719 +0,0 @@
|
||||
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;
|
||||
@@ -1,236 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type ISettlement from '@/domain/iSettlement.ts';
|
||||
import { SETTLEMENT_STATUS_MAP } from '@/domain/iSettlement.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import { downloadSettlement } from '@/api/recon/settlement.ts';
|
||||
import { Get } from '@/api/common/table.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 结算表(D9 生成于对账结算,D10 导出存档)
|
||||
*/
|
||||
const SettlementPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<ISettlement>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<ISettlement | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await Get<ISettlement>('/recon/settlement', id);
|
||||
setDetail(res.data.data ?? null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: XinTableColumn<ISettlement>[] = [
|
||||
{
|
||||
title: '结算单号',
|
||||
dataIndex: 'settlement_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
render: (_, record) => <Text copyable={{ text: record.settlement_no }}>{record.settlement_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 ?? '-',
|
||||
},
|
||||
{
|
||||
title: '来源对账单',
|
||||
dataIndex: 'recon',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) =>
|
||||
record.recon ? (
|
||||
<span>
|
||||
{record.recon.recon_no}
|
||||
<Text type="secondary">({record.recon.title})</Text>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结算周期',
|
||||
dataIndex: 'period',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
|
||||
},
|
||||
{
|
||||
title: '公布金额',
|
||||
dataIndex: 'total_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => `¥${record.total_amount}`,
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
dataIndex: 'actual_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => <Text strong>¥{record.actual_amount}</Text>,
|
||||
},
|
||||
{
|
||||
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(SETTLEMENT_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = SETTLEMENT_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '结算时间',
|
||||
dataIndex: 'settled_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.settled_at ?? '-',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<ISettlement>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
<AuthButton key="download" auth="recon.settlement.download">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'xlsx', label: '下载 Excel', onClick: () => downloadSettlement(record.id!, 'xlsx') },
|
||||
{ key: 'pdf', label: '下载 PDF', onClick: () => downloadSettlement(record.id!, 'pdf') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button size="small" type="primary" ghost icon={<DownloadOutlined />}>
|
||||
下载
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</AuthButton>,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<ISettlement> = {
|
||||
api: '/recon/settlement',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'recon.settlement',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
scroll: { x: 1200 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>结算表</Title>
|
||||
<Text type="secondary">
|
||||
由财务对账结算按门店聚合生成;支持 Excel / PDF 导出存档(回框统计表规则待业务确认后补充)。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<ISettlement> {...tableProps} />
|
||||
|
||||
<Drawer
|
||||
title={detail ? `结算表 ${detail.settlement_no}` : '结算表详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={640}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail ? (
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
{SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源对账单">
|
||||
{detail.recon?.recon_no ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算周期">
|
||||
{detail.period_start} ~ {detail.period_end}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="公布金额">¥{detail.total_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="差额">¥{detail.diff_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算时间">
|
||||
{detail.settled_at ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="存档文件" span={2}>
|
||||
{detail.file_path ?? <Text type="secondary">未导出</Text>}
|
||||
</Descriptions.Item>
|
||||
{detail.remark ? (
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
{detail.remark}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettlementPage;
|
||||
Reference in New Issue
Block a user