物流对账单

This commit is contained in:
2026-07-26 09:51:33 +08:00
parent 2b7ef65cce
commit 19cb312465
21 changed files with 1909 additions and 27 deletions
+2
View File
@@ -31,6 +31,7 @@ import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage'; import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage'; import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import TicketsPage from './pages/TicketsPage'; import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage'; import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage'; import InvoicesPage from './pages/InvoicesPage';
@@ -95,6 +96,7 @@ export default function App() {
<Route path="/finance/store-bills" element={<StoreBillsPage />} /> <Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} /> <Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} /> <Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} /> <Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} /> <Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} /> <Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
@@ -75,6 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/finance/store-bills', label: '门店账单' }, { key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' }, { key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' }, { key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
], ],
}, },
{ {
@@ -156,6 +157,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/finance/store-bills': 'finance', '/finance/store-bills': 'finance',
'/finance/partner-bills': 'finance', '/finance/partner-bills': 'finance',
'/finance/winery-bills': 'finance', '/finance/winery-bills': 'finance',
'/finance/logistics-bills': 'finance',
'benefit-group': 'benefit', 'benefit-group': 'benefit',
'/benefit/coupons': 'benefit', '/benefit/coupons': 'benefit',
'/benefit/ledgers': 'benefit', '/benefit/ledgers': 'benefit',
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
Button, Button,
Divider,
Form, Form,
Input, Input,
InputNumber,
Modal, Modal,
Select, Select,
Space, Space,
@@ -14,10 +16,13 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
DEFAULT_XFX_LOGISTICS_PRICING,
FULFILLMENT_PROVIDER_STATUS_LABELS, FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS, FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus, FulfillmentProviderStatus,
FulfillmentProviderType, FulfillmentProviderType,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
LogisticsSettlementMethod,
isXfxProviderCode, isXfxProviderCode,
type FulfillmentProviderDto, type FulfillmentProviderDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -32,6 +37,9 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([
value, value,
label, label,
})); }));
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
([value, label]) => ({ value, label }),
);
const SIGN_OPTIONS = [ const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' }, { value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' }, { value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
@@ -77,6 +85,12 @@ export default function FulfillmentProvidersPage() {
status: FulfillmentProviderStatus.ACTIVE, status: FulfillmentProviderStatus.ACTIVE,
apiUrl: DEFAULT_XFX_API_URL, apiUrl: DEFAULT_XFX_API_URL,
signType: 'MD5', signType: 'MD5',
settlementMethod: LogisticsSettlementMethod.PREPAID,
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
}); });
setOpen(true); setOpen(true);
} }
@@ -84,6 +98,7 @@ export default function FulfillmentProvidersPage() {
function openEdit(row: FulfillmentProviderDto) { function openEdit(row: FulfillmentProviderDto) {
setEditRow(row); setEditRow(row);
const xfx = row.xiaofeixiaConfig; const xfx = row.xiaofeixiaConfig;
const pricing = row.pricingRules;
form.setFieldsValue({ form.setFieldsValue({
code: row.code, code: row.code,
name: row.name, name: row.name,
@@ -94,6 +109,16 @@ export default function FulfillmentProvidersPage() {
apiKey: '', apiKey: '',
signType: xfx?.signType || 'MD5', signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '', appId: xfx?.appId || '',
bankAccountName: row.bankAccountName || '',
bankName: row.bankName || '',
bankBranch: row.bankBranch || '',
bankAccountNo: row.bankAccountNo || '',
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
}); });
setOpen(true); setOpen(true);
} }
@@ -104,6 +129,18 @@ export default function FulfillmentProvidersPage() {
name: v.name, name: v.name,
type: v.type, type: v.type,
status: v.status, status: v.status,
bankAccountName: v.bankAccountName || null,
bankName: v.bankName || null,
bankBranch: v.bankBranch || null,
bankAccountNo: v.bankAccountNo || null,
settlementMethod: v.settlementMethod,
pricingRules: {
baseBottles: Number(v.baseBottles),
baseFee: Number(v.baseFee),
extraBottleFee: Number(v.extraBottleFee),
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
},
}; };
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) { if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
@@ -144,6 +181,19 @@ export default function FulfillmentProvidersPage() {
dataIndex: 'type', dataIndex: 'type',
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v, render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
}, },
{
title: '结算',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 100,
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -188,7 +238,7 @@ export default function FulfillmentProvidersPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
使 API
</Typography.Text> </Typography.Text>
</div> </div>
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
@@ -200,7 +250,7 @@ export default function FulfillmentProvidersPage() {
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。" message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
/> />
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} /> <Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
@@ -210,7 +260,7 @@ export default function FulfillmentProvidersPage() {
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={() => void submit()} onOk={() => void submit()}
width={560} width={640}
destroyOnClose destroyOnClose
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
@@ -227,11 +277,58 @@ export default function FulfillmentProvidersPage() {
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
<Select options={SETTLEMENT_OPTIONS} />
</Form.Item>
<Form.Item name="bankAccountName" label="收款户名">
<Input />
</Form.Item>
<Form.Item name="bankName" label="开户银行">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户支行">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="银行账号">
<Input />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item
name="baseBottles"
label="起送瓶数"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="baseFee"
label="起送费用(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="extraBottleFee"
label="加一瓶(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
</Space>
{showXfxFields && ( {showXfxFields && (
<> <>
<Typography.Title level={5} style={{ marginTop: 8 }}> <Divider orientation="left"></Divider>
</Typography.Title>
<Form.Item <Form.Item
name="apiUrl" name="apiUrl"
label="API 地址" label="API 地址"
@@ -0,0 +1,625 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
LOGISTICS_SETTLEMENT_METHOD_LABELS,
type LogisticsSettlementMethod,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type BillRow = {
id: string;
billNo: string;
fulfillmentProviderId: string;
providerCode?: string;
providerName?: string;
periodStart: string;
periodEnd: string;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
settlementMethod: string;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
quantity: number;
logisticsAmount: number;
shippedAt: string;
};
type ProviderSummary = {
providerId: string;
providerCode: string;
providerName: string;
settlementMethod: string;
prepaidBalance: number;
bankAccountName?: string | null;
bankName?: string | null;
bankAccountNo?: string | null;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
billId?: string | null;
billStatus?: string | null;
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
};
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未结算',
PAID: '已结算',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
};
export default function LogisticsBillsPage() {
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
'/admin/logistics-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
const [rechargeForm] = Form.useForm();
useEffect(() => {
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
.catch(() => {});
}, []);
async function loadSummary(period: Dayjs) {
setSummaryLoading(true);
try {
const res = await request<{ items: ProviderSummary[] }>(
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
);
setSummaryRows(res.items ?? []);
} finally {
setSummaryLoading(false);
}
}
useEffect(() => {
void loadSummary(summaryPeriod);
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已确认结算');
setSelectedKeys([]);
reload();
void loadSummary(summaryPeriod);
},
});
}
async function openDetail(id: string) {
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(
`/admin/logistics-bills/export?${qs}`,
);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
async function generateBills() {
const v = await genForm.validateFields();
const month = v.month as Dayjs;
await request('/admin/logistics-bills/generate', {
method: 'POST',
body: JSON.stringify({
year: month.year(),
month: month.month() + 1,
providerId: v.providerId || undefined,
}),
});
message.success('已生成物流对账单');
setGenOpen(false);
reload();
void loadSummary(summaryPeriod);
}
async function submitRecharge() {
if (!rechargeTarget) return;
const v = await rechargeForm.validateFields();
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
method: 'POST',
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
});
message.success('充值成功');
setRechargeOpen(false);
void loadSummary(summaryPeriod);
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{
title: '承运商',
width: 140,
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
<Button
type="link"
size="small"
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
>
</Button>
)}
</Space>
),
},
];
const summaryColumns: ColumnsType<ProviderSummary> = [
{
title: '承运商',
render: (_, r) => `${r.providerName} (${r.providerCode})`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 110,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{
title: '计价标准',
width: 220,
render: (_, r) => {
const p = r.pricingRules;
if (!p) return '—';
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
p.boxBottles ? `${p.boxBottles}瓶箱¥${p.boxFee}` : ''
}`;
},
},
{
title: '银行账户',
width: 180,
ellipsis: true,
render: (_, r) =>
r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
: '—',
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '应付物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '账单',
width: 100,
render: (_, r) =>
r.billStatus ? (
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
{STATUS_LABELS[r.billStatus] || r.billStatus}
</Tag>
) : (
'未生成'
),
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.settlementMethod === 'PREPAID' ? (
<Button
type="link"
size="small"
onClick={() => {
setRechargeTarget(r);
rechargeForm.resetFields();
setRechargeOpen(true);
}}
>
</Button>
) : null,
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Tabs
items={[
{
key: 'summary',
label: '按承运商汇总',
children: (
<>
<Form
form={summaryForm}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{ month: summaryPeriod }}
onFinish={(v: { month?: Dayjs }) => {
const m = v.month || dayjs().subtract(1, 'month');
setSummaryPeriod(m);
}}
>
<Form.Item name="month" label="账期">
<DatePicker picker="month" allowClear={false} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="providerId"
loading={summaryLoading}
columns={summaryColumns}
dataSource={summaryRows}
pagination={false}
scroll={{ x: 1200 }}
/>
</>
),
},
{
key: 'bills',
label: '月账单',
children: (
<>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic
title="瓶数合计"
value={summary.bottleCount ?? 0}
/>
<Statistic
title="物流费合计"
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
prefix="¥"
precision={2}
/>
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: {
status?: string;
providerId?: string;
month?: Dayjs;
}) => {
setFilters({
status: v.status || '',
providerId: v.providerId || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'UNPAID', label: '未结算' },
{ value: 'PAID', label: '已结算' },
]}
/>
</Form.Item>
<Form.Item name="providerId" label="承运商">
<Select
allowClear
style={{ width: 180 }}
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期">
<DatePicker picker="month" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
setGenOpen(true);
}}
>
</Button>
<Button loading={exporting} onClick={() => void exportExcel()}>
</Button>
<Button
disabled={!selectedKeys.length}
onClick={() =>
confirmPay(
selectedKeys.map(String),
selectedAmount,
)
}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={billColumns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
/>
</>
),
},
]}
/>
<Drawer
title="物流对账明细"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail && (
<>
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="承运商">
{detail.providerName} ({detail.providerCode})
</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="物流费">
¥{Number(detail.logisticsAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
</Descriptions>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '发货时间',
dataIndex: 'shippedAt',
width: 160,
render: fmtTime,
},
]}
/>
</>
)}
</Drawer>
<Modal
title="生成物流月账单"
open={genOpen}
onCancel={() => setGenOpen(false)}
onOk={() => void generateBills()}
destroyOnClose
>
<Form form={genForm} layout="vertical">
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="providerId" label="承运商(空=全部)">
<Select
allowClear
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
destroyOnClose
>
<Form form={rechargeForm} layout="vertical">
<Form.Item
name="amount"
label="金额"
rules={[{ required: true, message: '请输入充值金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选,如预充运费" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+32
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
calcBenefitAmount, calcBenefitAmount,
calcRedeemSettleAmount, calcRedeemSettleAmount,
calcLogisticsFeeByBottles,
validateMinPurchase, validateMinPurchase,
validateRedeemAmount, validateRedeemAmount,
allocateBenefitCoupons, allocateBenefitCoupons,
@@ -67,6 +68,37 @@ describe('calcRedeemSettleAmount', () => {
}); });
}); });
describe('calcLogisticsFeeByBottles', () => {
const xfx = {
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
};
it('charges base for 1~2 bottles', () => {
expect(calcLogisticsFeeByBottles(1, xfx)).toBe(6);
expect(calcLogisticsFeeByBottles(2, xfx)).toBe(6);
});
it('adds extra bottle fee', () => {
expect(calcLogisticsFeeByBottles(3, xfx)).toBe(8);
expect(calcLogisticsFeeByBottles(5, xfx)).toBe(12);
});
it('uses box fee for full boxes', () => {
expect(calcLogisticsFeeByBottles(6, xfx)).toBe(14);
expect(calcLogisticsFeeByBottles(12, xfx)).toBe(28);
});
it('combines boxes with remainder ladder', () => {
expect(calcLogisticsFeeByBottles(7, xfx)).toBe(20);
expect(calcLogisticsFeeByBottles(8, xfx)).toBe(20);
expect(calcLogisticsFeeByBottles(9, xfx)).toBe(22);
});
});
describe('allocateBenefitCoupons', () => { describe('allocateBenefitCoupons', () => {
const coupons = [ const coupons = [
{ id: '1', balance: 300, createdAt: 1 }, { id: '1', balance: 300, createdAt: 1 },
+41
View File
@@ -95,6 +95,47 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
return Math.round(amount * settlementRate * 100) / 100; return Math.round(amount * settlementRate * 100) / 100;
} }
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
export type LogisticsPricingRule = {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
};
function roundMoney(n: number): number {
return Math.round(n * 100) / 100;
}
function calcLogisticsBottleLadder(quantity: number, rule: LogisticsPricingRule): number {
const qty = Math.floor(quantity);
if (qty <= 0) return 0;
if (qty <= rule.baseBottles) return rule.baseFee;
return rule.baseFee + (qty - rule.baseBottles) * rule.extraBottleFee;
}
/**
* 按承运商计价标准计算单票物流费。
* 配置了箱规时:整箱按 boxFee,余瓶按「起送瓶数/加瓶」阶梯。
*/
export function calcLogisticsFeeByBottles(quantity: number, rule: LogisticsPricingRule): number {
const qty = Math.floor(Number(quantity) || 0);
if (qty <= 0) return 0;
if (!(rule.baseBottles > 0) || !(rule.baseFee >= 0) || !(rule.extraBottleFee >= 0)) {
throw new Error('计价标准不完整');
}
const boxBottles = rule.boxBottles && rule.boxBottles > 0 ? rule.boxBottles : 0;
const boxFee = rule.boxFee != null && rule.boxFee >= 0 ? rule.boxFee : null;
if (boxBottles > 0 && boxFee != null) {
const boxes = Math.floor(qty / boxBottles);
const rem = qty % boxBottles;
return roundMoney(boxes * boxFee + calcLogisticsBottleLadder(rem, rule));
}
return roundMoney(calcLogisticsBottleLadder(qty, rule));
}
export function generateUserNo(): string { export function generateUserNo(): string {
const suffix = Math.floor(10000000 + Math.random() * 90000000); const suffix = Math.floor(10000000 + Math.random() * 90000000);
return `DK${suffix}`; return `DK${suffix}`;
+11
View File
@@ -179,6 +179,17 @@ export enum FulfillmentProviderStatus {
DISABLED = 'DISABLED', DISABLED = 'DISABLED',
} }
/** 物流承运商结算方式:前期充值,后期挂账月结 */
export enum LogisticsSettlementMethod {
PREPAID = 'PREPAID',
MONTHLY_CREDIT = 'MONTHLY_CREDIT',
}
export const LOGISTICS_SETTLEMENT_METHOD_LABELS: Record<LogisticsSettlementMethod, string> = {
[LogisticsSettlementMethod.PREPAID]: '充值扣款',
[LogisticsSettlementMethod.MONTHLY_CREDIT]: '挂账月结',
};
export enum WarehouseFulfillmentMode { export enum WarehouseFulfillmentMode {
API_AUTO = 'API_AUTO', API_AUTO = 'API_AUTO',
MANUAL = 'MANUAL', MANUAL = 'MANUAL',
@@ -1,5 +1,11 @@
import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums'; import type {
FulfillmentProviderStatus,
FulfillmentProviderType,
LogisticsSettlementMethod,
} from './enums';
import type { WarehouseFulfillmentMode } from './enums'; import type { WarehouseFulfillmentMode } from './enums';
import type { LogisticsPricingRuleDto } from './settlement';
import { DEFAULT_XFX_LOGISTICS_PRICING } from './settlement';
/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson */ /** 小飞侠仓配凭证(存 FulfillmentProvider.configJson */
export interface XiaofeixiaProviderConfig { export interface XiaofeixiaProviderConfig {
@@ -19,6 +25,13 @@ export interface XiaofeixiaProviderConfigPublic {
hasApiKey: boolean; hasApiKey: boolean;
} }
export interface FulfillmentProviderBankDto {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
}
export interface FulfillmentProviderDto { export interface FulfillmentProviderDto {
id: string; id: string;
code: string; code: string;
@@ -34,6 +47,14 @@ export interface FulfillmentProviderDto {
hasConfig: boolean; hasConfig: boolean;
/** 小飞侠等承运商结构化配置(脱敏) */ /** 小飞侠等承运商结构化配置(脱敏) */
xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null; xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null;
/** 结算银行账户 */
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
prepaidBalance: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -47,6 +68,12 @@ export interface CreateFulfillmentProviderInput {
capabilitiesJson?: string; capabilitiesJson?: string;
/** 结构化小飞侠配置;有则覆盖写入 configJson */ /** 结构化小飞侠配置;有则覆盖写入 configJson */
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>; xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
} }
export interface UpdateFulfillmentProviderInput { export interface UpdateFulfillmentProviderInput {
@@ -56,8 +83,16 @@ export interface UpdateFulfillmentProviderInput {
configJson?: string; configJson?: string;
capabilitiesJson?: string; capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>; xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null;
} }
export { DEFAULT_XFX_LOGISTICS_PRICING };
export interface ManualShipOrderInput { export interface ManualShipOrderInput {
logisticsCompany: string; logisticsCompany: string;
trackingNo: string; trackingNo: string;
+49
View File
@@ -90,6 +90,53 @@ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ /** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
export const WINERY_SETTLEMENT_RATE = 0.3; export const WINERY_SETTLEMENT_RATE = 0.3;
export type { LogisticsSettlementMethod } from './enums';
export { LOGISTICS_SETTLEMENT_METHOD_LABELS } from './enums';
import type { LogisticsSettlementMethod } from './enums';
/** 小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元 */
export const DEFAULT_XFX_LOGISTICS_PRICING = {
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
} as const;
export type LogisticsPricingRuleDto = {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
};
export interface LogisticsBillDto {
id: string;
billNo: string;
fulfillmentProviderId: string;
providerCode?: string;
providerName?: string;
periodStart: string;
periodEnd: string;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
settlementMethod: LogisticsSettlementMethod;
status: FinancePayStatus;
paidAt?: string | null;
pricingSnapshot?: LogisticsPricingRuleDto | null;
}
export interface LogisticsBillItemDto {
id: string;
orderId: string;
orderNo: string;
quantity: number;
logisticsAmount: number;
shippedAt: string;
}
export type FinanceBillSummary = { export type FinanceBillSummary = {
count: number; count: number;
redeemAmount?: number; redeemAmount?: number;
@@ -98,6 +145,8 @@ export type FinanceBillSummary = {
orderCommission?: number; orderCommission?: number;
redeemCommission?: number; redeemCommission?: number;
wineryAmount?: number; wineryAmount?: number;
logisticsAmount?: number;
bottleCount?: number;
totalAmount: number; totalAmount: number;
}; };
+80
View File
@@ -226,6 +226,18 @@ enum FinancePayStatus {
PAID PAID
} }
// 物流承运商结算方式:前期充值,后期挂账月结
enum LogisticsSettlementMethod {
PREPAID
MONTHLY_CREDIT
}
enum LogisticsPrepaidLedgerType {
RECHARGE
DEDUCT
ADJUST
}
enum StoreStatus { enum StoreStatus {
OPEN OPEN
PAUSED PAUSED
@@ -681,15 +693,83 @@ model FulfillmentProvider {
status FulfillmentProviderStatus @default(ACTIVE) status FulfillmentProviderStatus @default(ACTIVE)
configJson String? @map("config_json") @db.Text configJson String? @map("config_json") @db.Text
capabilitiesJson String? @map("capabilities_json") @db.Text capabilitiesJson String? @map("capabilities_json") @db.Text
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankName String? @map("bank_name") @db.VarChar(64)
bankBranch String? @map("bank_branch") @db.VarChar(128)
bankAccountNo String? @map("bank_account_no") @db.VarChar(64)
settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method")
pricingRulesJson String? @map("pricing_rules_json") @db.Text
prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
warehouses CityWarehouse[] warehouses CityWarehouse[]
deliveries OrderDelivery[] deliveries OrderDelivery[]
logisticsBills LogisticsBill[]
prepaidLedgers LogisticsPrepaidLedger[]
@@map("common_fulfillment_provider") @@map("common_fulfillment_provider")
} }
// 物流对账月账单(按承运商汇总)
model LogisticsBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
fulfillmentProviderId BigInt @map("fulfillment_provider_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCount Int @default(0) @map("order_count")
bottleCount Int @default(0) @map("bottle_count")
logisticsAmount Decimal @map("logistics_amount") @db.Decimal(12, 2)
settlementMethod LogisticsSettlementMethod @map("settlement_method")
pricingSnapshotJson String? @map("pricing_snapshot_json") @db.Text
status FinancePayStatus @default(UNPAID)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
fulfillmentProvider FulfillmentProvider @relation(fields: [fulfillmentProviderId], references: [id], onDelete: Restrict)
items LogisticsBillItem[]
prepaidLedgers LogisticsPrepaidLedger[]
@@unique([fulfillmentProviderId, periodStart])
@@index([status, periodStart])
@@map("logistics_bill")
}
model LogisticsBillItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
logisticsBillId BigInt @map("logistics_bill_id") @db.UnsignedBigInt
orderId BigInt @map("order_id") @db.UnsignedBigInt
orderNo String @map("order_no") @db.VarChar(32)
quantity Int
logisticsAmount Decimal @map("logistics_amount") @db.Decimal(10, 2)
shippedAt DateTime @map("shipped_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
logisticsBill LogisticsBill @relation(fields: [logisticsBillId], references: [id], onDelete: Cascade)
@@unique([logisticsBillId, orderId])
@@index([logisticsBillId])
@@map("logistics_bill_item")
}
model LogisticsPrepaidLedger {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
fulfillmentProviderId BigInt @map("fulfillment_provider_id") @db.UnsignedBigInt
type LogisticsPrepaidLedgerType
amount Decimal @db.Decimal(12, 2)
balanceAfter Decimal @map("balance_after") @db.Decimal(12, 2)
logisticsBillId BigInt? @map("logistics_bill_id") @db.UnsignedBigInt
remark String? @db.VarChar(256)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
fulfillmentProvider FulfillmentProvider @relation(fields: [fulfillmentProviderId], references: [id], onDelete: Restrict)
logisticsBill LogisticsBill? @relation(fields: [logisticsBillId], references: [id], onDelete: SetNull)
@@index([fulfillmentProviderId, createdAt])
@@map("logistics_prepaid_ledger")
}
model CityWarehouse { model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt cityId BigInt @map("city_id") @db.UnsignedBigInt
+8
View File
@@ -183,6 +183,14 @@ async function main() {
name: '小飞侠', name: '小飞侠',
type: 'API', type: 'API',
status: 'ACTIVE', status: 'ACTIVE',
settlementMethod: 'PREPAID',
pricingRulesJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
capabilitiesJson: JSON.stringify({ capabilitiesJson: JSON.stringify({
createShipment: true, createShipment: true,
getTrack: true, getTrack: true,
@@ -67,6 +67,9 @@ export const HqOperationAction = {
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT', PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM', WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM', WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN', REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM', REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
@@ -163,6 +166,9 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请', [HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款', [HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款', [HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
@@ -5,7 +5,7 @@ import { SettlementService } from '../modules/settlement/settlement.service';
/** /**
* 财务对账单定时任务(Asia/Shanghai * 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00 * - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00
* - 每月 1 日 08:00:合伙人上一自然月账单 * - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
*/ */
@Injectable() @Injectable()
export class SettlementScheduler { export class SettlementScheduler {
@@ -41,5 +41,13 @@ export class SettlementScheduler {
} catch (e) { } catch (e) {
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e); this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
} }
try {
const logistics = await this.settlementService.generatePreviousMonthLogisticsBills();
this.logger.log(
`Logistics bills: total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
);
} catch (e) {
this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e);
}
} }
} }
@@ -1,7 +1,14 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
import { import {
FulfillmentProviderStatus,
FulfillmentProviderType,
LogisticsSettlementMethod,
Prisma,
} from '@prisma/client';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
isXfxProviderCode, isXfxProviderCode,
type LogisticsPricingRuleDto,
type XiaofeixiaProviderConfig, type XiaofeixiaProviderConfig,
type XiaofeixiaProviderConfigPublic, type XiaofeixiaProviderConfigPublic,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -17,6 +24,12 @@ export type CreateFulfillmentProviderInput = {
configJson?: string; configJson?: string;
capabilitiesJson?: string; capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>; xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod | string;
pricingRules?: LogisticsPricingRuleDto | null;
}; };
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>; export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
@@ -106,6 +119,13 @@ export class FulfillmentProviderService {
this.assertXiaofeixiaConfigComplete(configJson, true); this.assertXiaofeixiaConfigComplete(configJson, true);
} }
const pricingRulesJson = this.resolvePricingRulesJson(
code,
input.pricingRules,
isXfxProviderCode(code) ? DEFAULT_XFX_LOGISTICS_PRICING : null,
);
const settlementMethod = this.parseSettlementMethod(input.settlementMethod) ?? 'PREPAID';
const row = await this.prisma.fulfillmentProvider.create({ const row = await this.prisma.fulfillmentProvider.create({
data: { data: {
code, code,
@@ -123,6 +143,12 @@ export class FulfillmentProviderService {
cancel: true, cancel: true,
}) })
: null), : null),
bankAccountName: this.normOptional(input.bankAccountName),
bankName: this.normOptional(input.bankName),
bankBranch: this.normOptional(input.bankBranch),
bankAccountNo: this.normOptional(input.bankAccountNo),
settlementMethod,
pricingRulesJson,
}, },
}); });
return this.toDto(row); return this.toDto(row);
@@ -142,6 +168,11 @@ export class FulfillmentProviderService {
this.assertXiaofeixiaConfigComplete(configJson, false); this.assertXiaofeixiaConfigComplete(configJson, false);
} }
const pricingRulesJson =
input.pricingRules !== undefined
? this.resolvePricingRulesJson(code, input.pricingRules, null)
: undefined;
const row = await this.prisma.fulfillmentProvider.update({ const row = await this.prisma.fulfillmentProvider.update({
where: { id }, where: { id },
data: { data: {
@@ -152,11 +183,87 @@ export class FulfillmentProviderService {
...(input.capabilitiesJson !== undefined ...(input.capabilitiesJson !== undefined
? { capabilitiesJson: input.capabilitiesJson?.trim() || null } ? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
: {}), : {}),
...(input.bankAccountName !== undefined
? { bankAccountName: this.normOptional(input.bankAccountName) }
: {}),
...(input.bankName !== undefined ? { bankName: this.normOptional(input.bankName) } : {}),
...(input.bankBranch !== undefined ? { bankBranch: this.normOptional(input.bankBranch) } : {}),
...(input.bankAccountNo !== undefined
? { bankAccountNo: this.normOptional(input.bankAccountNo) }
: {}),
...(input.settlementMethod !== undefined
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
: {}),
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
}, },
}); });
return this.toDto(row); return this.toDto(row);
} }
/** 充值(结算模块可复用) */
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
const rounded = Math.round(amount * 100) / 100;
const result = await this.prisma.$transaction(async (tx) => {
const row = await tx.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
const balanceAfter = Math.round((Number(row.prepaidBalance) + rounded) * 100) / 100;
const updated = await tx.fulfillmentProvider.update({
where: { id: providerId },
data: { prepaidBalance: balanceAfter },
});
const ledger = await tx.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: providerId,
type: 'RECHARGE',
amount: rounded,
balanceAfter,
remark: remark?.trim() || '充值',
},
});
return { provider: updated, ledger };
});
return serializeBigInt({
provider: this.toDto(result.provider),
ledger: result.ledger,
});
}
/** 账单扣减充值余额;余额不足返回 false */
async deductPrepaid(
providerId: bigint,
amount: number,
logisticsBillId: bigint,
tx?: Prisma.TransactionClient,
): Promise<{ ok: true; balanceAfter: number } | { ok: false; balance: number }> {
const client = tx ?? this.prisma;
const rounded = Math.round(amount * 100) / 100;
const row = await client.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
const balance = Number(row.prepaidBalance);
if (balance + 1e-9 < rounded) {
return { ok: false, balance };
}
const balanceAfter = Math.round((balance - rounded) * 100) / 100;
await client.fulfillmentProvider.update({
where: { id: providerId },
data: { prepaidBalance: balanceAfter },
});
await client.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: providerId,
type: 'DEDUCT',
amount: rounded,
balanceAfter,
logisticsBillId,
remark: '物流月账单扣款',
},
});
return { ok: true, balanceAfter };
}
parseCapabilities(raw: string | null): Capabilities | null { parseCapabilities(raw: string | null): Capabilities | null {
if (!raw) return null; if (!raw) return null;
try { try {
@@ -183,6 +290,63 @@ export class FulfillmentProviderService {
} }
} }
parsePricingRules(raw: string | null): LogisticsPricingRuleDto | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<LogisticsPricingRuleDto>;
if (!parsed || typeof parsed !== 'object') return null;
const baseBottles = Number(parsed.baseBottles);
const baseFee = Number(parsed.baseFee);
const extraBottleFee = Number(parsed.extraBottleFee);
if (!(baseBottles > 0) || !(baseFee >= 0) || !(extraBottleFee >= 0)) return null;
const rule: LogisticsPricingRuleDto = { baseBottles, baseFee, extraBottleFee };
if (parsed.boxBottles != null && Number(parsed.boxBottles) > 0) {
rule.boxBottles = Number(parsed.boxBottles);
}
if (parsed.boxFee != null && Number(parsed.boxFee) >= 0) {
rule.boxFee = Number(parsed.boxFee);
}
return rule;
} catch {
return null;
}
}
private resolvePricingRulesJson(
code: string,
input: LogisticsPricingRuleDto | null | undefined,
fallback: LogisticsPricingRuleDto | null,
): string | null {
if (input === null) return null;
const rule = input ?? fallback;
if (!rule) return isXfxProviderCode(code) ? JSON.stringify(DEFAULT_XFX_LOGISTICS_PRICING) : null;
if (!(rule.baseBottles > 0)) throw new BadRequestException('计价标准:起送瓶数须大于 0');
if (!(rule.baseFee >= 0)) throw new BadRequestException('计价标准:起送费用无效');
if (!(rule.extraBottleFee >= 0)) throw new BadRequestException('计价标准:加瓶费用无效');
return JSON.stringify({
baseBottles: Number(rule.baseBottles),
baseFee: Number(rule.baseFee),
extraBottleFee: Number(rule.extraBottleFee),
...(rule.boxBottles != null ? { boxBottles: Number(rule.boxBottles) } : {}),
...(rule.boxFee != null ? { boxFee: Number(rule.boxFee) } : {}),
});
}
private parseSettlementMethod(
raw?: LogisticsSettlementMethod | string | null,
): LogisticsSettlementMethod | null {
if (raw == null || raw === '') return null;
if (raw === 'PREPAID' || raw === 'MONTHLY_CREDIT') return raw;
throw new BadRequestException('结算方式仅支持 PREPAID / MONTHLY_CREDIT');
}
private normOptional(v?: string | null) {
if (v === undefined) return undefined;
if (v == null) return null;
const t = String(v).trim();
return t || null;
}
private resolveConfigJsonForWrite( private resolveConfigJsonForWrite(
code: string, code: string,
existingRaw: string | null, existingRaw: string | null,
@@ -242,6 +406,13 @@ export class FulfillmentProviderService {
status: string; status: string;
configJson: string | null; configJson: string | null;
capabilitiesJson: string | null; capabilitiesJson: string | null;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: string;
pricingRulesJson?: string | null;
prepaidBalance?: Prisma.Decimal | number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}) { }) {
@@ -256,6 +427,13 @@ export class FulfillmentProviderService {
xiaofeixiaConfig: isXfxProviderCode(row.code) xiaofeixiaConfig: isXfxProviderCode(row.code)
? this.toPublicXiaofeixiaConfig(row.configJson) ? this.toPublicXiaofeixiaConfig(row.configJson)
: null, : null,
bankAccountName: row.bankAccountName ?? null,
bankName: row.bankName ?? null,
bankBranch: row.bankBranch ?? null,
bankAccountNo: row.bankAccountNo ?? null,
settlementMethod: row.settlementMethod ?? 'PREPAID',
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
prepaidBalance: Number(row.prepaidBalance ?? 0),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}); });
@@ -5,6 +5,7 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import { import {
CreateFulfillmentProviderDto, CreateFulfillmentProviderDto,
RechargeFulfillmentProviderDto,
UpdateFulfillmentProviderDto, UpdateFulfillmentProviderDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
@@ -40,6 +41,12 @@ export class AdminFulfillmentProvidersController {
configJson: dto.configJson, configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson, capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig, xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
}); });
} }
@@ -63,6 +70,23 @@ export class AdminFulfillmentProvidersController {
configJson: dto.configJson, configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson, capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig, xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
}); });
} }
@Post(':id/recharge')
@HqOperation({
action: HqOperationAction.LOGISTICS_PROVIDER_RECHARGE,
refType: 'FULFILLMENT_PROVIDER',
refIdParam: 'id',
includeBody: true,
})
recharge(@Param('id') id: string, @Body() dto: RechargeFulfillmentProviderDto) {
return this.service.rechargePrepaid(BigInt(id), Number(dto.amount), dto.remark);
}
} }
@@ -593,6 +593,36 @@ export class CreateFulfillmentProviderDto {
signType?: 'MD5' | 'HMAC-SHA256'; signType?: 'MD5' | 'HMAC-SHA256';
appId?: string; appId?: string;
}; };
@IsOptional()
@IsString()
bankAccountName?: string | null;
@IsOptional()
@IsString()
bankName?: string | null;
@IsOptional()
@IsString()
bankBranch?: string | null;
@IsOptional()
@IsString()
bankAccountNo?: string | null;
@IsOptional()
@IsIn(['PREPAID', 'MONTHLY_CREDIT'])
settlementMethod?: 'PREPAID' | 'MONTHLY_CREDIT';
@IsOptional()
@IsObject()
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
} }
export class UpdateFulfillmentProviderDto { export class UpdateFulfillmentProviderDto {
@@ -625,6 +655,45 @@ export class UpdateFulfillmentProviderDto {
signType?: 'MD5' | 'HMAC-SHA256'; signType?: 'MD5' | 'HMAC-SHA256';
appId?: string; appId?: string;
}; };
@IsOptional()
@IsString()
bankAccountName?: string | null;
@IsOptional()
@IsString()
bankName?: string | null;
@IsOptional()
@IsString()
bankBranch?: string | null;
@IsOptional()
@IsString()
bankAccountNo?: string | null;
@IsOptional()
@IsIn(['PREPAID', 'MONTHLY_CREDIT'])
settlementMethod?: 'PREPAID' | 'MONTHLY_CREDIT';
@IsOptional()
@IsObject()
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
}
export class RechargeFulfillmentProviderDto {
@IsNumber()
amount: number;
@IsOptional()
@IsString()
remark?: string;
} }
export class ManualShipOrderDto { export class ManualShipOrderDto {
@@ -373,6 +373,86 @@ export class AdminWineryBillController {
} }
} }
@Controller('admin/logistics-bills')
@UseGuards(HqAuthGuard)
export class AdminLogisticsBillController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminLogisticsBills({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status,
providerId: query.providerId,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
});
}
@Get('provider-summary')
providerSummary(@Query() query: Record<string, string>) {
return this.settlementService.listLogisticsProviderSummary({
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
});
}
@Get('export')
export(@Query() query: Record<string, string>) {
return this.settlementService.exportAdminLogisticsBills({
status: query.status,
providerId: query.providerId,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
});
}
@Post('generate')
generate(@Body() body: { year: number; month: number; providerId?: string }) {
if (!body?.year || !body?.month) {
throw new BadRequestException('请指定年月');
}
if (body.providerId) {
return this.settlementService.generateLogisticsBill({
providerId: body.providerId,
year: body.year,
month: body.month,
});
}
return this.settlementService.generateAllLogisticsBills({
year: body.year,
month: body.month,
});
}
@Post('batch-confirm')
@HqOperation({
action: HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM,
refType: 'LOGISTICS_BILL',
batch: true,
includeBody: true,
})
batchConfirm(@Body() body: { ids: string[] }) {
return this.settlementService.batchConfirmLogisticsBills(body.ids ?? []);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.settlementService.getAdminLogisticsBill(BigInt(id));
}
@Post(':id/confirm')
@HqOperation({
action: HqOperationAction.LOGISTICS_BILL_CONFIRM,
refType: 'LOGISTICS_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmLogisticsBill(BigInt(id));
}
}
@Controller('partner/me') @Controller('partner/me')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class PartnerMeController { export class PartnerMeController {
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module'; import { IamModule } from '../iam/iam.module';
import { AnalyticsModule } from '../analytics/analytics.module'; import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module'; import { CityScopeModule } from '../city-scope/city-scope.module';
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
import { SettlementService } from './settlement.service'; import { SettlementService } from './settlement.service';
import { import {
AdminLogisticsBillController,
AdminPartnerBillController, AdminPartnerBillController,
AdminStoreBillController, AdminStoreBillController,
AdminStorePayoutController, AdminStorePayoutController,
@@ -14,7 +16,7 @@ import {
} from './settlement.controller'; } from './settlement.controller';
@Module({ @Module({
imports: [IamModule, AnalyticsModule, CityScopeModule], imports: [IamModule, AnalyticsModule, CityScopeModule, FulfillmentModule],
controllers: [ controllers: [
SettlementController, SettlementController,
PartnerMeController, PartnerMeController,
@@ -23,6 +25,7 @@ import {
AdminStoreBillController, AdminStoreBillController,
AdminPartnerBillController, AdminPartnerBillController,
AdminWineryBillController, AdminWineryBillController,
AdminLogisticsBillController,
], ],
providers: [SettlementService], providers: [SettlementService],
exports: [SettlementService], exports: [SettlementService],
@@ -1,10 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types'; import { DEFAULT_XFX_LOGISTICS_PRICING, WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import { calcLogisticsFeeByBottles, type LogisticsPricingRule } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service'; import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
function generateBillNo(prefix: string) { function generateBillNo(prefix: string) {
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -37,6 +39,7 @@ export class SettlementService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService, private readonly partnerCityService: PartnerCityService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
) {} ) {}
// ─── Store payout (line) ───────────────────────────── // ─── Store payout (line) ─────────────────────────────
@@ -1129,4 +1132,512 @@ export class SettlementService {
} }
return where; return where;
} }
// ─── Logistics bills (按承运商月结) ───────────────────
async generatePreviousMonthLogisticsBills(anchor = new Date()) {
const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1);
return this.generateAllLogisticsBills({
year: prev.getFullYear(),
month: prev.getMonth() + 1,
});
}
async generateAllLogisticsBills(body: { year: number; month: number }) {
const providers = await this.prisma.fulfillmentProvider.findMany({
where: { status: 'ACTIVE' },
orderBy: { code: 'asc' },
});
const results: Array<{
providerId: string;
providerCode: string;
providerName: string;
ok: boolean;
skipped?: boolean;
message?: string;
billId?: string;
}> = [];
for (const p of providers) {
try {
const bill = await this.generateLogisticsBill({
providerId: p.id.toString(),
year: body.year,
month: body.month,
});
results.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
ok: true,
skipped: Boolean(bill.skipped),
message: bill.reason,
billId: bill.bill?.id?.toString?.() ?? bill.bill?.id,
});
} catch (e) {
results.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
ok: false,
message: e instanceof Error ? e.message : '失败',
});
}
}
return {
total: providers.length,
success: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
};
}
async generateLogisticsBill(body: { providerId: string; year: number; month: number }) {
const fulfillmentProviderId = BigInt(body.providerId);
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: fulfillmentProviderId },
});
if (!provider) throw new NotFoundException('仓配承运商不存在');
const periodStart = new Date(body.year, body.month - 1, 1);
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
const existing = await this.prisma.logisticsBill.findUnique({
where: {
fulfillmentProviderId_periodStart: { fulfillmentProviderId, periodStart },
},
});
if (existing?.status === 'PAID') {
return {
skipped: true,
reason: '该月账单已结算',
bill: serializeBigInt(existing),
};
}
const pricing =
this.fulfillmentProviderService.parsePricingRules(provider.pricingRulesJson) ??
(provider.code.toUpperCase() === 'XFX' || provider.code.toUpperCase() === 'XIAOFEIXIA'
? { ...DEFAULT_XFX_LOGISTICS_PRICING }
: null);
if (!pricing) {
throw new BadRequestException(`承运商 ${provider.name} 未配置计价标准`);
}
const deliveries = await this.prisma.orderDelivery.findMany({
where: {
fulfillmentProviderId,
OR: [
{ shippingAt: { gte: periodStart, lte: periodEnd } },
{
shippingAt: null,
outWarehouseAt: { gte: periodStart, lte: periodEnd },
},
],
},
include: {
order: {
select: {
id: true,
orderNo: true,
quantity: true,
deliveryType: true,
payStatus: true,
},
},
},
orderBy: [{ shippingAt: 'asc' }, { outWarehouseAt: 'asc' }],
});
const eligible = deliveries.filter(
(d) =>
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
if (eligible.length === 0 && !existing) {
return { skipped: true, reason: '无发货订单', bill: null };
}
const items = eligible.map((d) => {
const qty = d.order.quantity;
const fee = calcLogisticsFeeByBottles(qty, pricing as LogisticsPricingRule);
const shippedAt = d.shippingAt ?? d.outWarehouseAt ?? periodStart;
return {
orderId: d.order.id,
orderNo: d.order.orderNo,
quantity: qty,
logisticsAmount: fee,
shippedAt,
};
});
const orderCount = items.length;
const bottleCount = items.reduce((s, i) => s + i.quantity, 0);
const logisticsAmount = round2(items.reduce((s, i) => s + i.logisticsAmount, 0));
const settlementMethod = provider.settlementMethod;
const pricingSnapshotJson = JSON.stringify(pricing);
const bill = await this.prisma.$transaction(async (tx) => {
const header = existing
? await tx.logisticsBill.update({
where: { id: existing.id },
data: {
periodEnd,
orderCount,
bottleCount,
logisticsAmount,
settlementMethod,
pricingSnapshotJson,
status: 'UNPAID',
paidAt: null,
},
})
: await tx.logisticsBill.create({
data: {
billNo: generateBillNo('LB'),
fulfillmentProviderId,
periodStart,
periodEnd,
orderCount,
bottleCount,
logisticsAmount,
settlementMethod,
pricingSnapshotJson,
status: 'UNPAID',
},
});
if (existing) {
await tx.logisticsBillItem.deleteMany({ where: { logisticsBillId: header.id } });
}
if (items.length > 0) {
await tx.logisticsBillItem.createMany({
data: items.map((i) => ({
logisticsBillId: header.id,
orderId: i.orderId,
orderNo: i.orderNo,
quantity: i.quantity,
logisticsAmount: i.logisticsAmount,
shippedAt: i.shippedAt,
})),
});
}
// 充值模式:余额充足则自动扣款并标记已结算
if (settlementMethod === 'PREPAID' && logisticsAmount > 0) {
const deducted = await this.fulfillmentProviderService.deductPrepaid(
fulfillmentProviderId,
logisticsAmount,
header.id,
tx,
);
if (deducted.ok) {
return tx.logisticsBill.update({
where: { id: header.id },
data: { status: 'PAID', paidAt: new Date() },
});
}
}
return header;
});
return {
skipped: false,
bill: serializeBigInt(bill),
};
}
async listAdminLogisticsBills(query: {
page?: number;
pageSize?: number;
status?: string;
providerId?: string;
year?: number;
month?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildLogisticsBillWhere(query);
const [rawItems, total, aggregates] = await Promise.all([
this.prisma.logisticsBill.findMany({
where,
orderBy: { periodStart: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
fulfillmentProvider: {
select: {
id: true,
code: true,
name: true,
settlementMethod: true,
prepaidBalance: true,
bankAccountName: true,
bankName: true,
bankAccountNo: true,
},
},
},
}),
this.prisma.logisticsBill.count({ where }),
this.prisma.logisticsBill.aggregate({
where,
_sum: { logisticsAmount: true, bottleCount: true, orderCount: true },
_count: true,
}),
]);
const items = rawItems.map((b) => ({
...b,
providerCode: b.fulfillmentProvider.code,
providerName: b.fulfillmentProvider.name,
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(b.pricingSnapshotJson),
}));
const summary = {
count: aggregates._count,
logisticsAmount: Number(aggregates._sum.logisticsAmount ?? 0),
bottleCount: Number(aggregates._sum.bottleCount ?? 0),
totalAmount: Number(aggregates._sum.logisticsAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
async listLogisticsProviderSummary(query: { year?: number; month?: number }) {
const year = query.year ?? new Date().getFullYear();
const month = query.month ?? new Date().getMonth() + 1;
const periodStart = new Date(year, month - 1, 1);
const periodEnd = new Date(year, month, 0, 23, 59, 59, 999);
const providers = await this.prisma.fulfillmentProvider.findMany({
orderBy: { code: 'asc' },
});
const rows = [];
for (const p of providers) {
const pricing =
this.fulfillmentProviderService.parsePricingRules(p.pricingRulesJson) ??
(p.code.toUpperCase() === 'XFX' || p.code.toUpperCase() === 'XIAOFEIXIA'
? { ...DEFAULT_XFX_LOGISTICS_PRICING }
: null);
const deliveries = await this.prisma.orderDelivery.findMany({
where: {
fulfillmentProviderId: p.id,
OR: [
{ shippingAt: { gte: periodStart, lte: periodEnd } },
{
shippingAt: null,
outWarehouseAt: { gte: periodStart, lte: periodEnd },
},
],
},
include: {
order: { select: { quantity: true, payStatus: true, deliveryType: true } },
},
});
const eligible = deliveries.filter(
(d) =>
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
const orderCount = eligible.length;
const bottleCount = eligible.reduce((s, d) => s + d.order.quantity, 0);
let logisticsAmount = 0;
if (pricing) {
logisticsAmount = round2(
eligible.reduce(
(s, d) => s + calcLogisticsFeeByBottles(d.order.quantity, pricing as LogisticsPricingRule),
0,
),
);
}
const bill = await this.prisma.logisticsBill.findUnique({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: p.id,
periodStart,
},
},
});
rows.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
settlementMethod: p.settlementMethod,
prepaidBalance: Number(p.prepaidBalance),
bankAccountName: p.bankAccountName,
bankName: p.bankName,
bankAccountNo: p.bankAccountNo,
pricingRules: pricing,
orderCount,
bottleCount,
logisticsAmount,
billId: bill?.id?.toString() ?? null,
billStatus: bill?.status ?? null,
billAmount: bill ? Number(bill.logisticsAmount) : null,
});
}
return { year, month, items: rows };
}
async getAdminLogisticsBill(id: bigint) {
const bill = await this.prisma.logisticsBill.findUnique({
where: { id },
include: {
fulfillmentProvider: true,
items: { orderBy: { shippedAt: 'desc' } },
},
});
if (!bill) throw new NotFoundException('物流对账单不存在');
return serializeBigInt({
...bill,
providerCode: bill.fulfillmentProvider.code,
providerName: bill.fulfillmentProvider.name,
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson),
});
}
async confirmLogisticsBill(id: bigint) {
const bill = await this.prisma.logisticsBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('物流对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认');
if (bill.settlementMethod === 'PREPAID') {
const deducted = await this.prisma.$transaction(async (tx) => {
const result = await this.fulfillmentProviderService.deductPrepaid(
bill.fulfillmentProviderId,
Number(bill.logisticsAmount),
bill.id,
tx,
);
if (!result.ok) {
throw new BadRequestException(
`充值余额不足(当前 ¥${result.balance.toFixed(2)},应付 ¥${Number(bill.logisticsAmount).toFixed(2)}`,
);
}
return tx.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
});
return serializeBigInt(deducted);
}
const updated = await this.prisma.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
return serializeBigInt(updated);
}
async batchConfirmLogisticsBills(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmLogisticsBill(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async exportAdminLogisticsBills(query: {
status?: string;
providerId?: string;
year?: number;
month?: number;
}) {
const where = this.buildLogisticsBillWhere(query);
const bills = await this.prisma.logisticsBill.findMany({
where,
include: {
fulfillmentProvider: { select: { code: true, name: true } },
items: true,
},
orderBy: { periodStart: 'desc' },
});
const header = [
'账单号',
'承运商编码',
'承运商名称',
'账期起',
'账期止',
'结算方式',
'订单号',
'瓶数',
'物流费',
'发货时间',
'账单状态',
].join(',');
const rows: string[] = [];
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
'',
b.bottleCount,
Number(b.logisticsAmount),
'',
b.status,
].join(','),
);
continue;
}
for (const item of b.items) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
csvEscape(item.orderNo),
item.quantity,
Number(item.logisticsAmount),
item.shippedAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
].join(','),
);
}
}
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length };
}
private buildLogisticsBillWhere(query: {
status?: string;
providerId?: string;
year?: number;
month?: number;
}): Prisma.LogisticsBillWhereInput {
const where: Prisma.LogisticsBillWhereInput = {};
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
if (query.providerId) where.fulfillmentProviderId = BigInt(query.providerId);
if (query.year && query.month) {
const periodStart = new Date(query.year, query.month - 1, 1);
const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999);
where.periodStart = { gte: periodStart, lte: periodEnd };
}
return where;
}
} }
+13 -2
View File
@@ -210,13 +210,23 @@
### 3.7 城市多仓与仓配(Wave 3 ### 3.7 城市多仓与仓配(Wave 3
- 一城多仓;每仓最多关联 1 名管仓合伙人 - 一城多仓;每仓最多关联 1 名管仓合伙人
- **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择 - **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择;**承运商需配置银行账户、结算方式(充值/挂账月结)、计价标准**
- **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板) - **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板)
- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单 - 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单
- 同城无仓 / 跨城:总部传统快递填单 - 同城无仓 / 跨城:总部传统快递填单
- 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同 - 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同
- 未关联合伙人的仓 → 总部直派 - 未关联合伙人的仓 → 总部直派
#### 3.7.1 物流对账(总部财务)
| 规则 | 结论 |
|------|------|
| 汇总维度 | 按快递/仓配承运商(`FulfillmentProvider`)独立月账单 |
| 计费口径 | 账期内已发货订单瓶数 × 承运商计价标准(账单落快照) |
| 小飞侠默认价 | **2 瓶 6 元**;每加 1 瓶 **+2 元**;**6 瓶一箱 14 元**(整箱按箱费,余瓶按起送阶梯) |
| 结算方式 | **前期充值扣款**`PREPAID`):总部向承运商账户充值,月账单自动/确认扣余额;**后期挂账月结**(`MONTHLY_CREDIT`):生成应付账单后总部确认打款 |
| 银行账户 | 每承运商配置收款户名/开户行/支行/账号,打款对照 |
| 出账节奏 | 自然月;每月 1 日汇总上月;可手工按承运商/月生成 |
### 3.8 弱网核销兜底(Wave 3 · OPT-006 ### 3.8 弱网核销兜底(Wave 3 · OPT-006
- Wave 1~2:网络异常明确提示+重试;总部预警+人工补核销 - Wave 1~2:网络异常明确提示+重试;总部预警+人工补核销
@@ -276,7 +286,7 @@
| 基础 | 商品管理;营销规则 1:1 权益;签约主体:山西领势酒业有限责任公司 | | 基础 | 商品管理;营销规则 1:1 权益;签约主体:山西领势酒业有限责任公司 |
| 城市合伙人 | 开通城市;创建合伙人(全城/区域+佣金);仓库管理(Wave 3) | | 城市合伙人 | 开通城市;创建合伙人(全城/区域+佣金);仓库管理(Wave 3) |
| 审核交易 | 门店审核;全量订单/权益/工单;发票 2 工作日;补核销 | | 审核交易 | 门店审核;全量订单/权益/工单;发票 2 工作日;补核销 |
| 结算 | 门店+合伙人双 Tab;提现审;白名单配置 | | 结算 | 门店+合伙人双 Tab;提现审;白名单配置**物流对账**(按承运商月结) |
| 增长 | 推广码;问卷;评价;腾讯位置热力图 | | 增长 | 推广码;问卷;评价;腾讯位置热力图 |
### 4.5 端职责矩阵 ### 4.5 端职责矩阵
@@ -332,6 +342,7 @@
- 订单佣金 = 订单金额 × 归属合伙人 order_commission_rate(支付成功快照) - 订单佣金 = 订单金额 × 归属合伙人 order_commission_rate(支付成功快照)
- 核销佣金 = 按核销金额占比从 verify_commission_rate 池释放(核销时快照) - 核销佣金 = 按核销金额占比从 verify_commission_rate 池释放(核销时快照)
- 门店结算 = 核销金额 × 60% - 门店结算 = 核销金额 × 60%
- 物流费 = 按承运商计价标准对单票瓶数计价(小飞侠默认见 §3.7.1)
- 核销码 TTL = **3 分钟**;试核销固定 **100 元** - 核销码 TTL = **3 分钟**;试核销固定 **100 元**
--- ---
+14 -4
View File
@@ -266,6 +266,7 @@
| 门店结算打款 | 财务 → 门店账单 | T+1 账期;确认打款 / 批量打款;导出 | | 门店结算打款 | 财务 → 门店账单 | T+1 账期;确认打款 / 批量打款;导出 |
| 合伙人佣金 | 财务 → 合伙人账单 | 月账确认与打款 | | 合伙人佣金 | 财务 → 合伙人账单 | 月账确认与打款 |
| 酒厂往来 | 财务 → 酒厂账单 | 与酒厂账户对照 | | 酒厂往来 | 财务 → 酒厂账单 | 与酒厂账户对照 |
| 物流对账 | 财务 → 物流对账 | 按承运商月结;充值/挂账 |
| 发票开具 | 发票管理 | 2 个工作日 SLA;回传 PDF/图片 | | 发票开具 | 发票管理 | 2 个工作日 SLA;回传 PDF/图片 |
| 收款账户 | 系统设置 → 酒厂银行账户 | 户名/开户行/账号 | | 收款账户 | 系统设置 → 酒厂银行账户 | 户名/开户行/账号 |
@@ -316,7 +317,7 @@
| 订单 / 推广码 | 全量订单;推广码与归因用户 | | 订单 / 推广码 | 全量订单;推广码与归因用户 |
| 门店 | 列表、分类、账户、资源 | | 门店 | 列表、分类、账户、资源 |
| 开城 | 城市、城市合伙人、仓库、仓配管理 | | 开城 | 城市、城市合伙人、仓库、仓配管理 |
| 财务 | 门店账单、合伙人账单、酒厂账单 | | 财务 | 门店账单、合伙人账单、酒厂账单、物流对账 |
| 好客权益 | 权益券、流水、核销记录、待处理核销、核销调试 | | 好客权益 | 权益券、流水、核销记录、待处理核销、核销调试 |
| 配送单 | 列表;小飞侠联调 | | 配送单 | 列表;小飞侠联调 |
| 工单 | 工单中心;技术支持 | | 工单 | 工单中心;技术支持 |
@@ -565,13 +566,22 @@ HQ 订单详情可查看收货地址、仓信息、配送单、权益券与核
- 总部与酒厂供货/回款往来核对 - 总部与酒厂供货/回款往来核对
- 打款对照 **系统设置 → 酒厂银行账户** 中的户名、开户行、账号 - 打款对照 **系统设置 → 酒厂银行账户** 中的户名、开户行、账号
### 11.4 财务日常 SOP(建议) ### 11.4 物流对账
- 按快递/仓配承运商(小飞侠等)汇总月度物流费
- 承运商在 **开城 → 仓配管理** 配置:银行账户、结算方式(充值扣款 / 挂账月结)、计价标准
- 小飞侠默认:2 瓶 6 元,加一瓶 +2 元,6 瓶一箱 14 元
- 前期充值:在「物流对账」汇总页充值;生成月账单时余额充足则自动扣款
- 后期挂账:月账单确认后打款至承运商银行账户
### 11.5 财务日常 SOP(建议)
1. 每日核对门店「未打款」账单与核销记录 1. 每日核对门店「未打款」账单与核销记录
2. 处理门店提现申请并在账期内标记打款 2. 处理门店提现申请并在账期内标记打款
3. 月结合伙人账单并完成打款确认 3. 月结合伙人账单并完成打款确认
4. 同步发票开具与退款工单对资金影响 4. 月结物流承运商账单(充值余额或挂账打款)
5. 异常走技术支持或售后工单留痕 5. 同步发票开具与退款工单对资金影响
6. 异常走技术支持或售后工单留痕
--- ---