物流对账单
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -674,22 +686,90 @@ model CommonCity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model FulfillmentProvider {
|
model FulfillmentProvider {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(32)
|
code String @unique @db.VarChar(32)
|
||||||
name String @db.VarChar(128)
|
name String @db.VarChar(128)
|
||||||
type FulfillmentProviderType
|
type FulfillmentProviderType
|
||||||
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
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
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)
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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. 异常走技术支持或售后工单留痕
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user