Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d555240295 | |||
| f35e1a3ba5 | |||
| d9725b1914 | |||
| ac94f5f5de | |||
| 874ae93a9b |
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -42,6 +44,7 @@ type PartnerRow = {
|
|||||||
districtCodes?: string[] | null;
|
districtCodes?: string[] | null;
|
||||||
orderCommissionRate?: number;
|
orderCommissionRate?: number;
|
||||||
redeemCommissionRate?: number;
|
redeemCommissionRate?: number;
|
||||||
|
storeCount?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
@@ -121,6 +124,7 @@ export default function CityPartnersPanel({
|
|||||||
maxPartnerCommissionRate = 0.05,
|
maxPartnerCommissionRate = 0.05,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
@@ -204,6 +208,31 @@ export default function CityPartnersPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deletePartner(row: { id: string; storeCount?: number }) {
|
||||||
|
if ((row.storeCount ?? 0) > 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '请先删除门店',
|
||||||
|
content: `该城市合伙人下还有 ${row.storeCount} 家门店,请先删除门店后再删除合伙人。`,
|
||||||
|
okText: '查看门店',
|
||||||
|
onOk: () => navigate(`/stores?partnerId=${row.id}`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('城市合伙人已删除');
|
||||||
|
if (detail?.id === row.id) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void loadPartners();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string) {
|
async function deleteSubAccount(subId: string) {
|
||||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||||
message.success('子账号已删除');
|
message.success('子账号已删除');
|
||||||
@@ -237,11 +266,24 @@ export default function CityPartnersPanel({
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
<Space size={0}>
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
</Button>
|
管理
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -272,7 +314,22 @@ export default function CityPartnersPanel({
|
|||||||
width={640}
|
width={640}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
extra={
|
||||||
|
<Space>
|
||||||
|
{detail ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(detail)}
|
||||||
|
>
|
||||||
|
<Button danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" onClick={() => void savePartner()}>保存</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -127,6 +127,7 @@ function formatApiError(err: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CityPartnersPage() {
|
export default function CityPartnersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
@@ -234,6 +235,30 @@ export default function CityPartnersPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deletePartner(row: { id: string; storeCount?: number }) {
|
||||||
|
if ((row.storeCount ?? 0) > 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '请先删除门店',
|
||||||
|
content: `该城市合伙人下还有 ${row.storeCount} 家门店,请先删除门店后再删除合伙人。`,
|
||||||
|
okText: '查看门店',
|
||||||
|
onOk: () => navigate(`/stores?partnerId=${row.id}`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('城市合伙人已删除');
|
||||||
|
if (detail?.id === row.id) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string, parentId: string) {
|
async function deleteSubAccount(subId: string, parentId: string) {
|
||||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||||
message.success('子账号已删除');
|
message.success('子账号已删除');
|
||||||
@@ -352,12 +377,25 @@ export default function CityPartnersPage() {
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
<Space size={0}>
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
</Button>
|
管理
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -491,9 +529,22 @@ export default function CityPartnersPage() {
|
|||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => void savePartner()}>
|
<Space>
|
||||||
保存
|
{detail ? (
|
||||||
</Button>
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(detail)}
|
||||||
|
>
|
||||||
|
<Button danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" onClick={() => void savePartner()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@@ -274,7 +274,14 @@ export default function DevPlanTasksPage() {
|
|||||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '内容', dataIndex: 'content' },
|
{
|
||||||
|
title: '内容',
|
||||||
|
dataIndex: 'content',
|
||||||
|
width: 360,
|
||||||
|
render: (v: string) => (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxWidth: 480 }}>{v}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '附件',
|
title: '附件',
|
||||||
width: 100,
|
width: 100,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Row = {
|
|||||||
redeemItems?: BillItemRow[];
|
redeemItems?: BillItemRow[];
|
||||||
partner?: {
|
partner?: {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
@@ -64,6 +65,7 @@ type Row = {
|
|||||||
};
|
};
|
||||||
partnerAccount?: {
|
partnerAccount?: {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
@@ -71,7 +73,7 @@ type Row = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string };
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_REVIEW: '待审核',
|
PENDING_REVIEW: '待审核',
|
||||||
@@ -89,6 +91,18 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
REJECTED: 'red',
|
REJECTED: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function partnerDashLabel(p?: {
|
||||||
|
companyName?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
} | null): string {
|
||||||
|
if (!p) return '—';
|
||||||
|
const company = (p.companyName || '').trim();
|
||||||
|
const person = (p.name || '').trim();
|
||||||
|
if (company && person) return `${company}-${person}`;
|
||||||
|
return company || person || (p.phone || '').trim() || '—';
|
||||||
|
}
|
||||||
|
|
||||||
export default function PartnerBillsPage() {
|
export default function PartnerBillsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -257,7 +271,7 @@ export default function PartnerBillsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
const partnerName = (r: Row) => partnerDashLabel(r.partnerAccount ?? r.partner);
|
||||||
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
|
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
|
||||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
||||||
@@ -275,7 +289,7 @@ export default function PartnerBillsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 160,
|
width: 200,
|
||||||
render: (_, r) => partnerName(r),
|
render: (_, r) => partnerName(r),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -406,7 +420,7 @@ export default function PartnerBillsPage() {
|
|||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
options={partners.map((p) => ({
|
options={partners.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: p.companyName || p.phone || p.id,
|
label: partnerDashLabel(p),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -503,7 +517,7 @@ export default function PartnerBillsPage() {
|
|||||||
placeholder="全部主合伙人"
|
placeholder="全部主合伙人"
|
||||||
options={partners.map((p) => ({
|
options={partners.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: p.companyName || p.phone || p.id,
|
label: partnerDashLabel(p),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Steps,
|
Steps,
|
||||||
@@ -302,6 +303,7 @@ export default function StoresPage() {
|
|||||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||||
const deepLinkStoreOpenedRef = useRef(false);
|
const deepLinkStoreOpenedRef = useRef(false);
|
||||||
|
|
||||||
@@ -372,6 +374,23 @@ export default function StoresPage() {
|
|||||||
.map((n) => ({ value: n.id, label: n.name }));
|
.map((n) => ({ value: n.id, label: n.name }));
|
||||||
}, [categoryTree, editCategoryParentId]);
|
}, [categoryTree, editCategoryParentId]);
|
||||||
|
|
||||||
|
async function deleteStore(id: string) {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/stores/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('门店及门店账号已删除');
|
||||||
|
if (detail && String(detail.id) === id) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
||||||
const [d, cats] = await Promise.all([
|
const [d, cats] = await Promise.all([
|
||||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||||
@@ -821,7 +840,7 @@ export default function StoresPage() {
|
|||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 280,
|
width: 320,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0} wrap>
|
<Space size={0} wrap>
|
||||||
@@ -861,6 +880,17 @@ export default function StoresPage() {
|
|||||||
>
|
>
|
||||||
提现
|
提现
|
||||||
</Button>
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该门店?"
|
||||||
|
description="将同时删除门店账号;若有关联订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deleteStore(row.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1034,6 +1064,17 @@ export default function StoresPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}} />
|
}} />
|
||||||
<Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}>保存修改</Button>
|
<Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}>保存修改</Button>
|
||||||
|
{detail?.id ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该门店?"
|
||||||
|
description="将同时删除门店账号;若有关联订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deleteStore(String(detail.id))}
|
||||||
|
>
|
||||||
|
<Button danger loading={deleting}>删除门店</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
)}>
|
)}>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
WINERY_SETTLEMENT_RATE,
|
WINERY_SETTLEMENT_RATE,
|
||||||
type SystemConfigFormResponse,
|
type SystemConfigFormResponse,
|
||||||
@@ -95,6 +96,7 @@ function canConfirmWineryPay(row: { status: string; wineryAmount: number | strin
|
|||||||
const DELIVERY_LABELS: Record<string, string> = {
|
const DELIVERY_LABELS: Record<string, string> = {
|
||||||
LOCAL: '同城',
|
LOCAL: '同城',
|
||||||
CROSS_CITY: '跨城',
|
CROSS_CITY: '跨城',
|
||||||
|
ON_SITE_PICKUP: '现场提货',
|
||||||
};
|
};
|
||||||
|
|
||||||
const WINERY_BANK_KEYS = [
|
const WINERY_BANK_KEYS = [
|
||||||
@@ -107,6 +109,7 @@ const WINERY_BANK_KEYS = [
|
|||||||
export default function WineryBillsPage() {
|
export default function WineryBillsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [bankForm] = Form.useForm<Record<string, string>>();
|
const [bankForm] = Form.useForm<Record<string, string>>();
|
||||||
|
const [reconcileForm] = Form.useForm<{ range?: [Dayjs, Dayjs] }>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/winery-bills',
|
'/admin/winery-bills',
|
||||||
@@ -122,6 +125,8 @@ export default function WineryBillsPage() {
|
|||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [reconciling, setReconciling] = useState(false);
|
||||||
|
const [reconcileOpen, setReconcileOpen] = useState(false);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
@@ -200,6 +205,33 @@ export default function WineryBillsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runReconcile(values: { range?: [Dayjs, Dayjs] }) {
|
||||||
|
setReconciling(true);
|
||||||
|
try {
|
||||||
|
const body: { dateFrom?: string; dateTo?: string } = {};
|
||||||
|
if (values.range?.[0]) body.dateFrom = values.range[0].format('YYYY-MM-DD');
|
||||||
|
if (values.range?.[1]) body.dateTo = values.range[1].format('YYYY-MM-DD');
|
||||||
|
const result = await request<{
|
||||||
|
generated: number;
|
||||||
|
skipped: number;
|
||||||
|
orphanOrders: number;
|
||||||
|
days: number;
|
||||||
|
}>('/admin/winery-bills/reconcile', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
message.success(
|
||||||
|
`核对完成:重算 ${result.generated} 天,跳过 ${result.skipped} 天,补入孤儿订单 ${result.orphanOrders} 笔`,
|
||||||
|
);
|
||||||
|
setReconcileOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '核对失败');
|
||||||
|
} finally {
|
||||||
|
setReconciling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openBankModal() {
|
async function openBankModal() {
|
||||||
setBankOpen(true);
|
setBankOpen(true);
|
||||||
setBankLoading(true);
|
setBankLoading(true);
|
||||||
@@ -314,13 +346,18 @@ export default function WineryBillsPage() {
|
|||||||
<AdminListHeader
|
<AdminListHeader
|
||||||
title="酒厂对账单"
|
title="酒厂对账单"
|
||||||
settings={settingsButton}
|
settings={settingsButton}
|
||||||
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
|
description={`T+3:每 3 天出一期(相对 2026-08-01),纳入上期 3 天同城/跨城/现场提货已付已完成订单(实付 × ${ratePct}%);应付为 0 仍出账「无需打款」`}
|
||||||
actions={
|
actions={
|
||||||
canEditWineryBank ? (
|
<Space>
|
||||||
<Button type="default" onClick={() => void openBankModal()}>
|
<Button type="primary" onClick={() => setReconcileOpen(true)}>
|
||||||
酒厂银行账户信息配置
|
核对补生成
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
{canEditWineryBank ? (
|
||||||
|
<Button type="default" onClick={() => void openBankModal()}>
|
||||||
|
酒厂银行账户信息配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -530,6 +567,32 @@ export default function WineryBillsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="核对补生成酒厂账单"
|
||||||
|
open={reconcileOpen}
|
||||||
|
onCancel={() => setReconcileOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
核对时会补齐同城配送、跨城配送、现场提货的已付已完成订单;并修复历史现场单缺完成时间的问题。
|
||||||
|
实付或应付为 0 也会出账,状态显示为「无需打款」。已打款账单不回刷。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form
|
||||||
|
form={reconcileForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ range: [dayjs().subtract(60, 'day'), dayjs()] }}
|
||||||
|
onFinish={(v) => void runReconcile(v)}
|
||||||
|
>
|
||||||
|
<Form.Item name="range" label="出账日区间(可留空则默认近 60 天)">
|
||||||
|
<DatePicker.RangePicker style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block loading={reconciling}>
|
||||||
|
开始核对
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -116,7 +116,8 @@
|
|||||||
- 确认打款时可填写打款凭证号(`paymentRef`),并可上传凭证照片(`paymentProofUrls`,最多 9 张);已打款后在详情与导出中展示
|
- 确认打款时可填写打款凭证号(`paymentRef`),并可上传凭证照片(`paymentProofUrls`,最多 9 张);已打款后在详情与导出中展示
|
||||||
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
||||||
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
|
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
|
||||||
- **账单日一律为出账当天的北京日历日**(Asia/Shanghai):门店 T+1、酒厂 T+3 日账单的 `billDate`;合伙人/物流月账的账期按北京自然月。酒厂 T+3 只决定纳入哪天完成的订单,账单日不是完成日。
|
- **账单日一律为出账当天的北京日历日**(Asia/Shanghai):门店 T+1、酒厂 T+3 期账单的 `billDate`;合伙人/物流月账的账期按北京自然月。酒厂 T+3 = **每 3 天出一期**,纳入上期 3 天完成订单。
|
||||||
|
- **酒厂账单与订单完全比对**(v4.0.6):全部已完成已付订单均入账(含现场提货);出账日无单或应付为 0 仍生成账单,业务状态展示「无需打款」(DB 仍为 `UNPAID`);支持按出账周期核对补生成漏账。
|
||||||
- 核销详情展示核销门店主账户收款信息
|
- 核销详情展示核销门店主账户收款信息
|
||||||
|
|
||||||
### 3.4~3.8 Wave 能力
|
### 3.4~3.8 Wave 能力
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
||||||
**后台 8 项**:商品/门店/订单/权益/核销/工单/日志/财务。
|
**后台 8 项**:商品/门店/订单/权益/核销/工单/日志/财务。
|
||||||
**开发计划任务**:状态 `TODO` 待开发 / `IN_PROGRESS` 开发中 / `DEVELOPED` 已开发 / `RELEASED` 已上线;任务列表「来源工单」链到 `/tickets/support?id=`。
|
**开发计划任务**:状态 `TODO` 待开发 / `IN_PROGRESS` 开发中 / `DEVELOPED` 已开发 / `RELEASED` 已上线;任务列表「来源工单」链到 `/tickets/support?id=`。
|
||||||
**财务账单日**:出账当天北京日历日(门店/酒厂 `billDate`;合伙人/物流账期按北京自然月)。禁止 `toISOString().slice(0,10)` 或服务器本地 `Date` 午夜当账单日。
|
**财务账单日**:出账当天北京日历日(门店/酒厂 `billDate`;合伙人/物流账期按北京自然月)。禁止 `toISOString().slice(0,10)` 或服务器本地 `Date` 午夜当账单日。酒厂应付为 0 仍出账(展示无需打款)。
|
||||||
|
|
||||||
## 6. 技术债(摘要)
|
## 6. 技术债(摘要)
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -1,8 +1,8 @@
|
|||||||
# 杜康好客 · V4 PRD
|
# 杜康好客 · V4 PRD
|
||||||
|
|
||||||
> **v4.0**(2026-08-29)· 关联码与分佣事实源
|
> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账与订单完全比对
|
||||||
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图)。
|
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账)。
|
||||||
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
||||||
|
|
||||||
## 0. 版本
|
## 0. 版本
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| 4.0.1 | 08-29 / 08-30 | 合伙人关联码;订单佣金只认关联;账单酒单/核销分列;去掉区县酒单佣金;HQ 关联筛选与快链;合伙人 H5 用户管理与首页统计 | [`v4.0.1`](./杜康好客-v4.0.1-开发文档.md) |
|
| 4.0.1 | 08-29 / 08-30 | 合伙人关联码;订单佣金只认关联;账单酒单/核销分列;去掉区县酒单佣金;HQ 关联筛选与快链;合伙人 H5 用户管理与首页统计 | [`v4.0.1`](./杜康好客-v4.0.1-开发文档.md) |
|
||||||
| 4.0.2 | 08-30 | HQ 活动图模板(底图 + 方形码栏 + 文案);合伙人下载合成关联码;所选图写入库并作为用户管理主图 | [`v4.0.2`](./杜康好客-v4.0.2-开发文档.md) |
|
| 4.0.2 | 08-30 | HQ 活动图模板(底图 + 方形码栏 + 文案);合伙人下载合成关联码;所选图写入库并作为用户管理主图 | [`v4.0.2`](./杜康好客-v4.0.2-开发文档.md) |
|
||||||
|
| 4.0.6 | 08-31 | 酒厂 T+3=每 3 天出一期;全部已完成已付订单(含现场提货);应付为 0 仍出账;核对补生成 | [`v4.0.6`](./杜康好客-v4.0.6-开发文档.md) |
|
||||||
|
|
||||||
## 1. 锚点(沿用 V3,佣金归属改写)
|
## 1. 锚点(沿用 V3,佣金归属改写)
|
||||||
|
|
||||||
@@ -75,6 +76,13 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
|||||||
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
||||||
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
||||||
|
|
||||||
## 7. 不做
|
## 7. 酒厂对账(v4.0.6)
|
||||||
|
|
||||||
|
- 口径:T+3 = **每 3 个自然日出具一张账单**(对齐起点 `2026-08-01`,出账日如 8/4、8/7、8/10…);纳入窗口为上一期 3 天 `[出账日−3, 出账日)` 内全部 **COMPLETED + PAID** 订单(含现场提货);`billDate` = 出账当天北京日历日;应付 = 实付合计 × 30%。
|
||||||
|
- **与订单完全比对**:上述订单均须进入对应出账日账单明细;漏账可通过 HQ「核对补生成」或每日任务回扫补齐。
|
||||||
|
- **应付为 0 仍出账**:出账日无订单或应付为 0 仍生成账单;业务状态展示「无需打款」(DB 仍为 `UNPAID`,不可确认打款)。
|
||||||
|
- **已打款不回刷**;未打款账单可重算;明细 `orderId` 冲突时从其他未打款账单迁入。
|
||||||
|
|
||||||
|
## 8. 不做
|
||||||
|
|
||||||
改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
||||||
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
||||||
|
|
||||||
## 0. 总览(2026-08-30)
|
## 0. 总览(2026-08-31)
|
||||||
|
|
||||||
| 维度 | 结论 |
|
| 维度 | 结论 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 版本线 | **v4.0.2** 活动图模板 + 关联码合成 |
|
| 版本线 | **v4.0.6** 酒厂对账与订单完全比对 |
|
||||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
| 账单 | 酒订单 / 核销订单分列 |
|
| 账单 | 酒订单 / 核销订单分列;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;用户管理主图跟选择走 |
|
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;用户管理主图跟选择走 |
|
||||||
|
|
||||||
## 1. 版本交付
|
## 1. 版本交付
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 4.0.1 | [`关联码与分佣账单`](./杜康好客-v4.0.1-开发文档.md) | ✅ 已实现 |
|
| 4.0.1 | [`关联码与分佣账单`](./杜康好客-v4.0.1-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.2 | [`活动图模板与关联码合成`](./杜康好客-v4.0.2-开发文档.md) | ✅ 已实现 |
|
| 4.0.2 | [`活动图模板与关联码合成`](./杜康好客-v4.0.2-开发文档.md) | ✅ 已实现 |
|
||||||
|
| 4.0.6 | [`酒厂对账核对`](./杜康好客-v4.0.6-开发文档.md) | ✅ 已实现 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -25,3 +26,4 @@
|
|||||||
| 2026-08-30 | HQ 订单/用户详情展示关联合伙人;改绑权限 `users_partner_assoc`;用户/订单筛选关联合伙人;开城关联用户快链 |
|
| 2026-08-30 | HQ 订单/用户详情展示关联合伙人;改绑权限 `users_partner_assoc`;用户/订单筛选关联合伙人;开城关联用户快链 |
|
||||||
| 2026-08-30 | 合伙人 H5:首页关联用户/关联用户订单统计;用户管理 Tab(关联码+列表+备注);`partner_user_note` 与 HQ `hqRemark` 隔离 |
|
| 2026-08-30 | 合伙人 H5:首页关联用户/关联用户订单统计;用户管理 Tab(关联码+列表+备注);`partner_user_note` 与 HQ `hqRemark` 隔离 |
|
||||||
| 2026-08-30 | v4.0.2:HQ 活动图(底图 + 拖拽码栏 + 文案);合伙人复制文案、下载合成关联码;选择写入库,用户管理主图下次登录仍显示 |
|
| 2026-08-30 | v4.0.2:HQ 活动图(底图 + 拖拽码栏 + 文案);合伙人复制文案、下载合成关联码;选择写入库,用户管理主图下次登录仍显示 |
|
||||||
|
| 2026-08-31 | v4.0.6:酒厂 T+3=每 3 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 |
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 杜康好客 · v4.0.6 开发文档
|
||||||
|
|
||||||
|
> **2026-08-31** · settlement / admin-web
|
||||||
|
> **主题**:酒厂 T+3 = 每 3 天出一期;与全部已完成已付订单完全比对(含现场提货);应付为 0 仍出账(无需打款);核对补生成
|
||||||
|
|
||||||
|
需求事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) §7 · V3 财务锚点仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) §3.3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | T+3 出账节奏 | 修 | **每 3 天一张账单**(非每日);纳入上期 3 个自然日全部已付已完成订单 |
|
||||||
|
| 2 | 含现场提货 | 修 | 不限配送类型 |
|
||||||
|
| 3 | 零应付 / 无单仍出账 | 修 | 出账日仍生成账单,展示「无需打款」 |
|
||||||
|
| 4 | 核对补生成 | 需求 | HQ 按区间重算周期出账日;清理旧「每日一账」空账单 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
对齐起点 `WINERY_BILL_EPOCH_YMD = 2026-08-01`,周期 `WINERY_SETTLEMENT_PERIOD_DAYS = 3`。
|
||||||
|
|
||||||
|
| 出账日 | 纳入完成日窗口 |
|
||||||
|
|--------|----------------|
|
||||||
|
| 8/4 | [8/1, 8/4) |
|
||||||
|
| 8/7 | [8/4, 8/7) |
|
||||||
|
| 8/28 | [8/25, 8/28) |
|
||||||
|
| 8/31 | [8/28, 8/31) |
|
||||||
|
|
||||||
|
- 应付 = `round2(Σ payAmount × 0.3)`
|
||||||
|
- `wineryAmount === 0` → UI/导出「无需打款」;确认打款拒绝
|
||||||
|
- 非周期日不生成;每日 cron 仅在出账日真正落账
|
||||||
|
|
||||||
|
### 旧逻辑为何对不上
|
||||||
|
|
||||||
|
旧实现是「**每天**出账,只收 **3 天前那一天** 的订单」:当天窗口无完成单就 skip → 8/4~8/28 大量空白;8/28、8/29 连着出是因为两天各自都有「D−3」完成单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 变更面
|
||||||
|
|
||||||
|
| 位置 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `packages/domain` | `isWineryIssueDay` / `shanghaiWineryPeriodWindow` / `wineryIssueDateFromCompletedAt` / `listWineryIssueDays` |
|
||||||
|
| `shared-types` | `WINERY_SETTLEMENT_PERIOD_DAYS`、`WINERY_BILL_EPOCH_YMD` |
|
||||||
|
| `settlement.service` | 周期出账;`reconcileWineryBills` 只跑周期日并清理非周期空账单 |
|
||||||
|
| `settlement.scheduler` | 每日尝试出账(非周期日 skip)+ 近 45 日核对 |
|
||||||
|
| `admin-web` | 文案 + 核对补生成 |
|
||||||
|
|
||||||
|
无 Prisma 迁移。线上历史账单一次性修复:`server/dukang-api/prisma/sql/fix-winery-bills-v406.sql`(补现场提货入账、中间 0 账、迁错期明细)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 验收
|
||||||
|
|
||||||
|
- [ ] 账单日仅为 8/4、8/7、8/10… 这类周期日,不会连续两天各出一张(除非跨期)
|
||||||
|
- [ ] 核对补生成后,同城配送与现场提货订单均出现在对应期账单明细(历史现场单缺 `completedAt` 会自动用 `paidAt` 补齐)
|
||||||
|
- [ ] 每张账单明细覆盖上期 3 天全部已付已完成订单(含现场提货/同城/跨城)
|
||||||
|
- [ ] 出账日无单仍有账单,「无需打款」
|
||||||
|
- [ ] HQ「核对补生成」可选 8/4~今天,补齐中间漏期并清理误生成的每日账单
|
||||||
|
- [ ] 已打款账单不回刷
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
assertCanDeleteCityPartner,
|
||||||
|
assertCanDeleteStore,
|
||||||
validatePartnerCityBinding,
|
validatePartnerCityBinding,
|
||||||
validatePartnerCommissionRates,
|
validatePartnerCommissionRates,
|
||||||
} from './city-partner';
|
} from './city-partner';
|
||||||
@@ -70,3 +72,33 @@ describe('validatePartnerCommissionRates', () => {
|
|||||||
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
|
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('assertCanDeleteCityPartner', () => {
|
||||||
|
it('requires stores to be deleted first', () => {
|
||||||
|
const result = assertCanDeleteCityPartner({ storeCount: 2, orderCount: 0, redeemCount: 0 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('请先删除门店');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks when orders exist', () => {
|
||||||
|
const result = assertCanDeleteCityPartner({ storeCount: 0, orderCount: 1, redeemCount: 0 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('关联订单');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows delete when empty', () => {
|
||||||
|
expect(assertCanDeleteCityPartner({ storeCount: 0, orderCount: 0, redeemCount: 0 }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertCanDeleteStore', () => {
|
||||||
|
it('blocks when redeem records exist', () => {
|
||||||
|
const result = assertCanDeleteStore({ orderCount: 0, redeemCount: 3 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('关联核销单');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows delete when no orders or redeems', () => {
|
||||||
|
expect(assertCanDeleteStore({ orderCount: 0, redeemCount: 0 }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,3 +66,33 @@ export function validatePartnerCommissionRates(
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertCanDeleteCityPartner(input: {
|
||||||
|
storeCount: number;
|
||||||
|
orderCount: number;
|
||||||
|
redeemCount: number;
|
||||||
|
}): PartnerCityValidationResult {
|
||||||
|
if (input.storeCount > 0) {
|
||||||
|
return { ok: false, message: '该城市合伙人下还有门店,请先删除门店' };
|
||||||
|
}
|
||||||
|
if (input.orderCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联订单,禁止删除' };
|
||||||
|
}
|
||||||
|
if (input.redeemCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联核销单,禁止删除' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCanDeleteStore(input: {
|
||||||
|
orderCount: number;
|
||||||
|
redeemCount: number;
|
||||||
|
}): PartnerCityValidationResult {
|
||||||
|
if (input.orderCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联订单,禁止删除' };
|
||||||
|
}
|
||||||
|
if (input.redeemCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联核销单,禁止删除' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
resolveTaskStatusFromVersions,
|
||||||
|
taskStatusFromVersionStatus,
|
||||||
|
} from './dev-plan';
|
||||||
|
|
||||||
|
describe('taskStatusFromVersionStatus', () => {
|
||||||
|
it('maps version status to task status', () => {
|
||||||
|
expect(taskStatusFromVersionStatus('PENDING')).toBe('TODO');
|
||||||
|
expect(taskStatusFromVersionStatus('IN_PROGRESS')).toBe('IN_PROGRESS');
|
||||||
|
expect(taskStatusFromVersionStatus('TESTING')).toBe('DEVELOPED');
|
||||||
|
expect(taskStatusFromVersionStatus('RELEASED')).toBe('RELEASED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTaskStatusFromVersions', () => {
|
||||||
|
it('returns null when no versions', () => {
|
||||||
|
expect(resolveTaskStatusFromVersions([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the highest mapped status across versions', () => {
|
||||||
|
expect(resolveTaskStatusFromVersions(['PENDING', 'TESTING'])).toBe('DEVELOPED');
|
||||||
|
expect(resolveTaskStatusFromVersions(['IN_PROGRESS', 'RELEASED'])).toBe('RELEASED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,3 +61,36 @@ export function taskCompletedAtOnStatus(
|
|||||||
void prev;
|
void prev;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TASK_STATUS_RANK: Record<DevPlanTaskStatus, number> = {
|
||||||
|
TODO: 0,
|
||||||
|
IN_PROGRESS: 1,
|
||||||
|
DEVELOPED: 2,
|
||||||
|
RELEASED: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 版本状态 → 任务状态:待开始/开发中/测试中/已发布 ↔ 待处理/进行中/已开发/已发布 */
|
||||||
|
export function taskStatusFromVersionStatus(status: DevPlanVersionStatus): DevPlanTaskStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return 'TODO';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case 'TESTING':
|
||||||
|
return 'DEVELOPED';
|
||||||
|
case 'RELEASED':
|
||||||
|
return 'RELEASED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务挂多个版本时取进度最高的对应状态 */
|
||||||
|
export function resolveTaskStatusFromVersions(
|
||||||
|
versionStatuses: DevPlanVersionStatus[],
|
||||||
|
): DevPlanTaskStatus | null {
|
||||||
|
if (!versionStatuses.length) return null;
|
||||||
|
return versionStatuses
|
||||||
|
.map(taskStatusFromVersionStatus)
|
||||||
|
.reduce((highest, current) =>
|
||||||
|
TASK_STATUS_RANK[current] > TASK_STATUS_RANK[highest] ? current : highest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
addShanghaiDays,
|
addShanghaiDays,
|
||||||
|
isWineryIssueDay,
|
||||||
|
listWineryIssueDays,
|
||||||
previousShanghaiMonth,
|
previousShanghaiMonth,
|
||||||
shanghaiLaggedIssueWindow,
|
shanghaiLaggedIssueWindow,
|
||||||
shanghaiMonthLastInstant,
|
shanghaiMonthLastInstant,
|
||||||
shanghaiMonthRange,
|
shanghaiMonthRange,
|
||||||
shanghaiPeriodYmds,
|
shanghaiPeriodYmds,
|
||||||
shanghaiT1DayWindow,
|
shanghaiT1DayWindow,
|
||||||
|
shanghaiWineryPeriodWindow,
|
||||||
shanghaiYmd,
|
shanghaiYmd,
|
||||||
startOfShanghaiDay,
|
startOfShanghaiDay,
|
||||||
|
wineryIssueDateFromCompletedAt,
|
||||||
} from './shanghai-date';
|
} from './shanghai-date';
|
||||||
|
|
||||||
describe('shanghaiYmd / startOfShanghaiDay', () => {
|
describe('shanghaiYmd / startOfShanghaiDay', () => {
|
||||||
@@ -46,7 +50,7 @@ describe('shanghaiT1DayWindow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('shanghaiLaggedIssueWindow', () => {
|
describe('shanghaiLaggedIssueWindow', () => {
|
||||||
it('酒厂 T+3:出账日是今天,纳入 3 天前完成的订单', () => {
|
it('旧日滞后:出账日是今天,纳入 3 天前完成的订单', () => {
|
||||||
const { start, end, billDate } = shanghaiLaggedIssueWindow(
|
const { start, end, billDate } = shanghaiLaggedIssueWindow(
|
||||||
new Date('2026-08-31T00:00:00.000Z'),
|
new Date('2026-08-31T00:00:00.000Z'),
|
||||||
3,
|
3,
|
||||||
@@ -57,6 +61,55 @@ describe('shanghaiLaggedIssueWindow', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('酒厂 T+3 每 3 天一期', () => {
|
||||||
|
const epoch = '2026-08-01';
|
||||||
|
const period = 3;
|
||||||
|
|
||||||
|
it('出账日:8/4、8/7、8/28;非 8/5、8/29', () => {
|
||||||
|
expect(isWineryIssueDay(new Date('2026-08-04T00:00:00+08:00'), period, epoch)).toBe(true);
|
||||||
|
expect(isWineryIssueDay(new Date('2026-08-07T00:00:00+08:00'), period, epoch)).toBe(true);
|
||||||
|
expect(isWineryIssueDay(new Date('2026-08-28T00:00:00+08:00'), period, epoch)).toBe(true);
|
||||||
|
expect(isWineryIssueDay(new Date('2026-08-01T00:00:00+08:00'), period, epoch)).toBe(false);
|
||||||
|
expect(isWineryIssueDay(new Date('2026-08-05T00:00:00+08:00'), period, epoch)).toBe(false);
|
||||||
|
expect(isWineryIssueDay(new Date('2026-08-29T00:00:00+08:00'), period, epoch)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('窗口:出账日 8/28 → 完成日 [8/25, 8/28)', () => {
|
||||||
|
const { start, end, billDate } = shanghaiWineryPeriodWindow(
|
||||||
|
new Date('2026-08-28T00:00:00+08:00'),
|
||||||
|
period,
|
||||||
|
);
|
||||||
|
expect(shanghaiYmd(billDate)).toBe('2026-08-28');
|
||||||
|
expect(shanghaiYmd(start)).toBe('2026-08-25');
|
||||||
|
expect(shanghaiYmd(end)).toBe('2026-08-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('完成日归属出账日', () => {
|
||||||
|
expect(
|
||||||
|
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-01T04:00:00.000Z'), period, epoch)),
|
||||||
|
).toBe('2026-08-04');
|
||||||
|
expect(
|
||||||
|
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-03T04:00:00.000Z'), period, epoch)),
|
||||||
|
).toBe('2026-08-04');
|
||||||
|
expect(
|
||||||
|
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-04T04:00:00.000Z'), period, epoch)),
|
||||||
|
).toBe('2026-08-07');
|
||||||
|
expect(
|
||||||
|
shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-27T04:00:00.000Z'), period, epoch)),
|
||||||
|
).toBe('2026-08-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listWineryIssueDays 只返回周期日', () => {
|
||||||
|
const days = listWineryIssueDays(
|
||||||
|
new Date('2026-08-01T00:00:00+08:00'),
|
||||||
|
new Date('2026-08-10T00:00:00+08:00'),
|
||||||
|
period,
|
||||||
|
epoch,
|
||||||
|
).map(shanghaiYmd);
|
||||||
|
expect(days).toEqual(['2026-08-04', '2026-08-07', '2026-08-10']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('addShanghaiDays', () => {
|
describe('addShanghaiDays', () => {
|
||||||
it('跨月跨年按北京日历加减', () => {
|
it('跨月跨年按北京日历加减', () => {
|
||||||
expect(shanghaiYmd(addShanghaiDays(new Date('2026-08-31T16:00:00.000Z'), 1))).toBe(
|
expect(shanghaiYmd(addShanghaiDays(new Date('2026-08-31T16:00:00.000Z'), 1))).toBe(
|
||||||
@@ -78,23 +131,15 @@ describe('shanghaiMonthRange', () => {
|
|||||||
|
|
||||||
it('UTC 月末 23:59:59 不会把账期止日滚成下月', () => {
|
it('UTC 月末 23:59:59 不会把账期止日滚成下月', () => {
|
||||||
const periodStart = new Date('2026-08-01T00:00:00.000Z');
|
const periodStart = new Date('2026-08-01T00:00:00.000Z');
|
||||||
expect(shanghaiPeriodYmds(periodStart)).toEqual({
|
const ymds = shanghaiPeriodYmds(periodStart);
|
||||||
periodStart: '2026-08-01',
|
expect(ymds.periodStart).toBe('2026-08-01');
|
||||||
periodEnd: '2026-08-31',
|
expect(ymds.periodEnd).toBe('2026-08-31');
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('previousShanghaiMonth', () => {
|
describe('previousShanghaiMonth', () => {
|
||||||
it('北京 9 月 1 日 08:00(UTC 9/1 00:00)取 8 月', () => {
|
it('北京 9 月 1 日取上月为 8 月', () => {
|
||||||
expect(previousShanghaiMonth(new Date('2026-09-01T00:00:00.000Z'))).toEqual({
|
expect(previousShanghaiMonth(new Date('2026-08-31T16:00:00.000Z'))).toEqual({
|
||||||
year: 2026,
|
|
||||||
month: 8,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('北京 9 月 1 日 00:30(UTC 8/31 16:30)取 8 月,不因 UTC 仍是 8 月而错到 7 月', () => {
|
|
||||||
expect(previousShanghaiMonth(new Date('2026-08-31T16:30:00.000Z'))).toEqual({
|
|
||||||
year: 2026,
|
year: 2026,
|
||||||
month: 8,
|
month: 8,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function shanghaiT1DayWindow(anchor = new Date()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 酒厂 T+N:出账日 = 今天;纳入窗口为「今天 − lagDays」当天完成的订单
|
* 酒厂日滞后窗口(旧:每天出账只收 lagDays 前那一天)。
|
||||||
|
* 现网酒厂已改为「每 periodDays 天出一期」,见 shanghaiWineryPeriodWindow。
|
||||||
*/
|
*/
|
||||||
export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number) {
|
export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number) {
|
||||||
const billDate = startOfShanghaiDay(anchor);
|
const billDate = startOfShanghaiDay(anchor);
|
||||||
@@ -42,6 +43,58 @@ export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number)
|
|||||||
return { start, end, billDate };
|
return { start, end, billDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 两个北京自然日之间相差天数(b − a;可为负) */
|
||||||
|
export function shanghaiCalendarDaysBetween(a: Date, b: Date): number {
|
||||||
|
const sa = startOfShanghaiDay(a).getTime();
|
||||||
|
const sb = startOfShanghaiDay(b).getTime();
|
||||||
|
return Math.round((sb - sa) / (24 * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否酒厂出账日:相对 epoch,每 periodDays 天一次(首张 = epoch + periodDays) */
|
||||||
|
export function isWineryIssueDay(anchor: Date, periodDays: number, epochYmd: string): boolean {
|
||||||
|
const billDate = startOfShanghaiDay(anchor);
|
||||||
|
const epoch = parseShanghaiYmd(epochYmd);
|
||||||
|
const diff = shanghaiCalendarDaysBetween(epoch, billDate);
|
||||||
|
return diff >= periodDays && diff % periodDays === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 酒厂 T+N 期账单窗口:出账日 = anchor 当天;纳入 [出账日 − periodDays, 出账日) 内完成的订单。
|
||||||
|
* 调用方应先用 isWineryIssueDay 判断是否出账日。
|
||||||
|
*/
|
||||||
|
export function shanghaiWineryPeriodWindow(anchor = new Date(), periodDays: number) {
|
||||||
|
const billDate = startOfShanghaiDay(anchor);
|
||||||
|
const start = addShanghaiDays(billDate, -periodDays);
|
||||||
|
return { start, end: billDate, billDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单完成日 → 所属酒厂出账日(落入下一期出账日) */
|
||||||
|
export function wineryIssueDateFromCompletedAt(
|
||||||
|
completedAt: Date,
|
||||||
|
periodDays: number,
|
||||||
|
epochYmd: string,
|
||||||
|
) {
|
||||||
|
const c = startOfShanghaiDay(completedAt);
|
||||||
|
const epoch = parseShanghaiYmd(epochYmd);
|
||||||
|
const daysFromEpoch = shanghaiCalendarDaysBetween(epoch, c);
|
||||||
|
if (daysFromEpoch < 0) {
|
||||||
|
return addShanghaiDays(epoch, periodDays);
|
||||||
|
}
|
||||||
|
const k = Math.floor(daysFromEpoch / periodDays) + 1;
|
||||||
|
return addShanghaiDays(epoch, k * periodDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 枚举 [from, to] 内(含)的全部酒厂出账日 */
|
||||||
|
export function listWineryIssueDays(from: Date, to: Date, periodDays: number, epochYmd: string): Date[] {
|
||||||
|
const start = startOfShanghaiDay(from);
|
||||||
|
const end = startOfShanghaiDay(to);
|
||||||
|
const out: Date[] = [];
|
||||||
|
for (let d = start; d.getTime() <= end.getTime(); d = addShanghaiDays(d, 1)) {
|
||||||
|
if (isWineryIssueDay(d, periodDays, epochYmd)) out.push(d);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function shanghaiMonthRange(year: number, month: number): { start: Date; endExclusive: Date } {
|
export function shanghaiMonthRange(year: number, month: number): { start: Date; endExclusive: Date } {
|
||||||
const start = new Date(`${year}-${pad2(month)}-01T00:00:00+08:00`);
|
const start = new Date(`${year}-${pad2(month)}-01T00:00:00+08:00`);
|
||||||
const endMonth = month === 12 ? 1 : month + 1;
|
const endMonth = month === 12 ? 1 : month + 1;
|
||||||
|
|||||||
@@ -187,12 +187,16 @@ export interface StoreBillDto {
|
|||||||
export interface WineryBillDto {
|
export interface WineryBillDto {
|
||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
/** 出账日 YYYY-MM-DD(北京时间;T+3 只决定纳入哪天完成的订单,不是账单日) */
|
/** 出账日 YYYY-MM-DD(北京时间;T+3 = 每 3 天一期,纳入上期 3 天完成订单) */
|
||||||
billDate: string;
|
billDate: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
orderAmount: number;
|
orderAmount: number;
|
||||||
wineryRate: number;
|
wineryRate: number;
|
||||||
wineryAmount: number;
|
wineryAmount: number;
|
||||||
|
/**
|
||||||
|
* DB:UNPAID | PAID。
|
||||||
|
* 业务展示:wineryAmount === 0 时视为「无需打款」(NO_PAYMENT_NEEDED),仍保留账单以便与订单完全比对。
|
||||||
|
*/
|
||||||
status: FinancePayStatus;
|
status: FinancePayStatus;
|
||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
}
|
}
|
||||||
@@ -210,11 +214,20 @@ export interface WineryBillItemDto {
|
|||||||
/** 门店结算默认比例(核销金额 × 比例) */
|
/** 门店结算默认比例(核销金额 × 比例) */
|
||||||
export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
|
export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
|
||||||
|
|
||||||
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
|
/** 酒厂账单结算比例(订单实付 × 比例),暂定 30%;含现场提货/同城/跨城 */
|
||||||
export const WINERY_SETTLEMENT_RATE = 0.3;
|
export const WINERY_SETTLEMENT_RATE = 0.3;
|
||||||
|
|
||||||
/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入当天出账的账单;账单日=出账当天北京日历日) */
|
/**
|
||||||
export const WINERY_SETTLEMENT_LAG_DAYS = 3;
|
* 酒厂 T+3:每 3 个自然日出具一张账单;出账日纳入上一期 3 天内完成的已付订单。
|
||||||
|
* 例:出账日 8/4 → 完成日 [8/1, 8/4);下一张 8/7 → [8/4, 8/7)。
|
||||||
|
*/
|
||||||
|
export const WINERY_SETTLEMENT_PERIOD_DAYS = 3;
|
||||||
|
|
||||||
|
/** @deprecated 使用 WINERY_SETTLEMENT_PERIOD_DAYS;保留别名避免旧引用断裂 */
|
||||||
|
export const WINERY_SETTLEMENT_LAG_DAYS = WINERY_SETTLEMENT_PERIOD_DAYS;
|
||||||
|
|
||||||
|
/** 酒厂账期对齐起点(北京日);首张出账日 = 起点 + PERIOD_DAYS */
|
||||||
|
export const WINERY_BILL_EPOCH_YMD = '2026-08-01';
|
||||||
|
|
||||||
export type { LogisticsSettlementMethod } from './enums';
|
export type { LogisticsSettlementMethod } from './enums';
|
||||||
export { LOGISTICS_SETTLEMENT_METHOD_LABELS } from './enums';
|
export { LOGISTICS_SETTLEMENT_METHOD_LABELS } from './enums';
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 酒厂账单一次性修复(v4.0.6)
|
||||||
|
-- 库:生产 dukang_prod(也可在已同步的本地 dukang_haoke 先演练)
|
||||||
|
--
|
||||||
|
-- 规则(与代码一致):
|
||||||
|
-- 起点 WINERY_BILL_EPOCH_YMD = 2026-08-01
|
||||||
|
-- 周期 WINERY_SETTLEMENT_PERIOD_DAYS = 3
|
||||||
|
-- 出账日 8/4、8/7、8/10 … ;纳入窗口 [出账日-3, 出账日)
|
||||||
|
-- 订单:status=COMPLETED AND pay_status=PAID
|
||||||
|
-- delivery_type IN ('LOCAL','CROSS_CITY','ON_SITE_PICKUP')
|
||||||
|
-- 账期归属:completed_at(缺则 paid_at)的北京日历日
|
||||||
|
-- 应付 = ROUND(实付合计 × 0.3, 2);出账日无单仍留账单(无需打款)
|
||||||
|
-- 已打款账单不改明细
|
||||||
|
-- 起点之前已入账的订单(如 7/28 同城挂在 7/31)保持原账单,不迁到 8/4
|
||||||
|
--
|
||||||
|
-- 本脚本会:
|
||||||
|
-- 1) 补现场提货等缺 completed_at 的已完成单
|
||||||
|
-- 2) 按周期补齐出账日账单(含中间应付为 0 的空账)
|
||||||
|
-- 3) 把未入账 / 挂错期(如 8/29)的同城、跨城、现场提货订单迁入对应出账日
|
||||||
|
-- 4) 按明细重算未打款表头
|
||||||
|
-- 5) 删除未打款、无明细、且非周期日的空账单(7/29、8/1、8/29 等)
|
||||||
|
--
|
||||||
|
-- 建议:先部署含「每 3 天出一期」的 API,再跑本脚本。
|
||||||
|
-- 若必须先修数据:跑完后尽快发版,避免旧「每日只收 D-3 当天」任务回刷 8/28。
|
||||||
|
--
|
||||||
|
-- 用法(生产):
|
||||||
|
-- mysql -u... -p dukang_prod < fix-winery-bills-v406.sql
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
-- 1) 现场提货等:支付即完成但未写 completed_at
|
||||||
|
UPDATE user_order
|
||||||
|
SET completed_at = paid_at
|
||||||
|
WHERE status = 'COMPLETED'
|
||||||
|
AND pay_status = 'PAID'
|
||||||
|
AND completed_at IS NULL
|
||||||
|
AND paid_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2) 补齐周期出账日(8/4 起至「今天北京日」能落到的最后一张)
|
||||||
|
INSERT INTO winery_bill (
|
||||||
|
bill_no, bill_date, order_count, order_amount, winery_rate, winery_amount, status, created_at
|
||||||
|
)
|
||||||
|
WITH RECURSIVE issue_days AS (
|
||||||
|
SELECT DATE('2026-08-04') AS issue_ymd
|
||||||
|
UNION ALL
|
||||||
|
SELECT DATE_ADD(issue_ymd, INTERVAL 3 DAY)
|
||||||
|
FROM issue_days
|
||||||
|
WHERE DATE_ADD(issue_ymd, INTERVAL 3 DAY) <= DATE(DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR))
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CONCAT('WBFIX', DATE_FORMAT(d.issue_ymd, '%Y%m%d')) AS bill_no,
|
||||||
|
TIMESTAMP(DATE_ADD(d.issue_ymd, INTERVAL -8 HOUR)) AS bill_date,
|
||||||
|
0,
|
||||||
|
0.00,
|
||||||
|
0.3000,
|
||||||
|
0.00,
|
||||||
|
'UNPAID',
|
||||||
|
TIMESTAMP(DATE_ADD(d.issue_ymd, INTERVAL -8 HOUR))
|
||||||
|
FROM issue_days d
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM winery_bill b
|
||||||
|
WHERE b.bill_date = TIMESTAMP(DATE_ADD(d.issue_ymd, INTERVAL -8 HOUR))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3) 起点日及之后:从未打款账单上拿掉挂错期的明细(已打款账单因 status 过滤不会动)
|
||||||
|
DELETE i
|
||||||
|
FROM winery_bill_item i
|
||||||
|
INNER JOIN winery_bill b ON b.id = i.winery_bill_id
|
||||||
|
INNER JOIN user_order o ON o.id = i.order_id
|
||||||
|
INNER JOIN winery_bill target
|
||||||
|
ON target.bill_date = TIMESTAMP(DATE_ADD(
|
||||||
|
DATE_ADD(
|
||||||
|
DATE('2026-08-01'),
|
||||||
|
INTERVAL (FLOOR(DATEDIFF(
|
||||||
|
DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')),
|
||||||
|
DATE('2026-08-01')
|
||||||
|
) / 3) + 1) * 3 DAY
|
||||||
|
),
|
||||||
|
INTERVAL -8 HOUR
|
||||||
|
))
|
||||||
|
WHERE b.status = 'UNPAID'
|
||||||
|
AND b.id <> target.id
|
||||||
|
AND o.status = 'COMPLETED'
|
||||||
|
AND o.pay_status = 'PAID'
|
||||||
|
AND o.delivery_type IN ('LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP')
|
||||||
|
AND COALESCE(o.completed_at, o.paid_at) IS NOT NULL
|
||||||
|
AND DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')) >= '2026-08-01';
|
||||||
|
|
||||||
|
-- 4) 把未入账的同城/跨城/现场提货订单写入对应周期账单
|
||||||
|
INSERT INTO winery_bill_item (
|
||||||
|
winery_bill_id, order_id, order_no, delivery_type, pay_amount, winery_amount, paid_at, created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
target.id,
|
||||||
|
o.id,
|
||||||
|
o.order_no,
|
||||||
|
o.delivery_type,
|
||||||
|
o.pay_amount,
|
||||||
|
ROUND(o.pay_amount * 0.3, 2),
|
||||||
|
COALESCE(o.paid_at, o.completed_at),
|
||||||
|
UTC_TIMESTAMP(3)
|
||||||
|
FROM user_order o
|
||||||
|
INNER JOIN winery_bill target
|
||||||
|
ON target.bill_date = TIMESTAMP(DATE_ADD(
|
||||||
|
DATE_ADD(
|
||||||
|
DATE('2026-08-01'),
|
||||||
|
INTERVAL (FLOOR(DATEDIFF(
|
||||||
|
DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')),
|
||||||
|
DATE('2026-08-01')
|
||||||
|
) / 3) + 1) * 3 DAY
|
||||||
|
),
|
||||||
|
INTERVAL -8 HOUR
|
||||||
|
))
|
||||||
|
LEFT JOIN winery_bill_item existing ON existing.order_id = o.id
|
||||||
|
WHERE o.status = 'COMPLETED'
|
||||||
|
AND o.pay_status = 'PAID'
|
||||||
|
AND o.delivery_type IN ('LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP')
|
||||||
|
AND COALESCE(o.completed_at, o.paid_at) IS NOT NULL
|
||||||
|
AND DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')) >= '2026-08-01'
|
||||||
|
AND existing.id IS NULL;
|
||||||
|
|
||||||
|
-- 5) 按明细重算所有未打款表头(应付为 0 的周期账单也保留)
|
||||||
|
UPDATE winery_bill b
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
winery_bill_id,
|
||||||
|
COUNT(*) AS order_count,
|
||||||
|
ROUND(SUM(pay_amount), 2) AS order_amount
|
||||||
|
FROM winery_bill_item
|
||||||
|
GROUP BY winery_bill_id
|
||||||
|
) x ON x.winery_bill_id = b.id
|
||||||
|
SET
|
||||||
|
b.order_count = IFNULL(x.order_count, 0),
|
||||||
|
b.order_amount = IFNULL(x.order_amount, 0.00),
|
||||||
|
b.winery_rate = 0.3000,
|
||||||
|
b.winery_amount = ROUND(IFNULL(x.order_amount, 0) * 0.3, 2)
|
||||||
|
WHERE b.status = 'UNPAID';
|
||||||
|
|
||||||
|
-- 6) 清掉误生成的非周期日空账单(有明细的如 7/31 保留;8/1 虽 MOD=0 但未满 3 天,仍删)
|
||||||
|
DELETE b
|
||||||
|
FROM winery_bill b
|
||||||
|
LEFT JOIN winery_bill_item i ON i.winery_bill_id = b.id
|
||||||
|
WHERE b.status = 'UNPAID'
|
||||||
|
AND i.id IS NULL
|
||||||
|
AND NOT (
|
||||||
|
DATEDIFF(DATE(CONVERT_TZ(b.bill_date, '+00:00', '+08:00')), DATE('2026-08-01')) >= 3
|
||||||
|
AND MOD(DATEDIFF(DATE(CONVERT_TZ(b.bill_date, '+00:00', '+08:00')), DATE('2026-08-01')), 3) = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 核对(不改数据)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
SELECT '=== 账单列表 ===' AS s;
|
||||||
|
SELECT
|
||||||
|
DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')) AS bill_ymd,
|
||||||
|
bill_no,
|
||||||
|
order_count,
|
||||||
|
order_amount,
|
||||||
|
winery_amount,
|
||||||
|
status
|
||||||
|
FROM winery_bill
|
||||||
|
ORDER BY bill_date;
|
||||||
|
|
||||||
|
SELECT '=== 明细按配送类型 ===' AS s;
|
||||||
|
SELECT delivery_type, COUNT(*) AS cnt, ROUND(SUM(pay_amount), 2) AS pay_sum
|
||||||
|
FROM winery_bill_item
|
||||||
|
GROUP BY delivery_type;
|
||||||
|
|
||||||
|
SELECT '=== 应入账但未入账(应为 0 行)===' AS s;
|
||||||
|
SELECT o.id, o.order_no, o.delivery_type, o.pay_amount,
|
||||||
|
DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')) AS attr_ymd
|
||||||
|
FROM user_order o
|
||||||
|
LEFT JOIN winery_bill_item i ON i.order_id = o.id
|
||||||
|
WHERE o.status = 'COMPLETED'
|
||||||
|
AND o.pay_status = 'PAID'
|
||||||
|
AND o.delivery_type IN ('LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP')
|
||||||
|
AND i.id IS NULL;
|
||||||
|
|
||||||
|
SELECT '=== 非周期日仍在的账单(仅允许起点前已入账的,如 7/31)===' AS s;
|
||||||
|
SELECT
|
||||||
|
DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')) AS bill_ymd,
|
||||||
|
bill_no,
|
||||||
|
order_count,
|
||||||
|
order_amount,
|
||||||
|
status
|
||||||
|
FROM winery_bill
|
||||||
|
WHERE NOT (
|
||||||
|
DATEDIFF(DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')), DATE('2026-08-01')) >= 3
|
||||||
|
AND MOD(DATEDIFF(DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')), DATE('2026-08-01')), 3) = 0
|
||||||
|
)
|
||||||
|
ORDER BY bill_date;
|
||||||
@@ -11,6 +11,7 @@ export const HqOperationAction = {
|
|||||||
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
|
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
|
||||||
PARTNER_CREATE: 'PARTNER_CREATE',
|
PARTNER_CREATE: 'PARTNER_CREATE',
|
||||||
PARTNER_UPDATE: 'PARTNER_UPDATE',
|
PARTNER_UPDATE: 'PARTNER_UPDATE',
|
||||||
|
PARTNER_DELETE: 'PARTNER_DELETE',
|
||||||
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
|
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
|
||||||
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
||||||
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
|
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
|
||||||
@@ -29,6 +30,7 @@ export const HqOperationAction = {
|
|||||||
ORDER_EXPORT: 'ORDER_EXPORT',
|
ORDER_EXPORT: 'ORDER_EXPORT',
|
||||||
STORE_CREATE: 'STORE_CREATE',
|
STORE_CREATE: 'STORE_CREATE',
|
||||||
STORE_UPDATE: 'STORE_UPDATE',
|
STORE_UPDATE: 'STORE_UPDATE',
|
||||||
|
STORE_DELETE: 'STORE_DELETE',
|
||||||
STORE_STATUS: 'STORE_STATUS',
|
STORE_STATUS: 'STORE_STATUS',
|
||||||
STORE_AUDIT: 'STORE_AUDIT',
|
STORE_AUDIT: 'STORE_AUDIT',
|
||||||
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
||||||
@@ -75,6 +77,7 @@ 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',
|
||||||
|
WINERY_BILL_RECONCILE: 'WINERY_BILL_RECONCILE',
|
||||||
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
|
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
|
||||||
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
|
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
|
||||||
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
|
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
|
||||||
@@ -142,6 +145,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
|
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
|
||||||
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
|
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
|
||||||
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
|
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
|
||||||
|
[HqOperationAction.PARTNER_DELETE]: '删除城市合伙人',
|
||||||
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
||||||
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
||||||
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
|
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
|
||||||
@@ -160,6 +164,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.ORDER_EXPORT]: '导出订单',
|
[HqOperationAction.ORDER_EXPORT]: '导出订单',
|
||||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||||
|
[HqOperationAction.STORE_DELETE]: '删除门店',
|
||||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||||
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
||||||
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
||||||
@@ -206,6 +211,7 @@ 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.WINERY_BILL_RECONCILE]: '酒厂对账单核对补生成',
|
||||||
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
|
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
|
||||||
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
|
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
|
||||||
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
|
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { shanghaiYmd } from '@dukang/domain';
|
import { addShanghaiDays, shanghaiYmd, startOfShanghaiDay } from '@dukang/domain';
|
||||||
import { SettlementService } from '../modules/settlement/settlement.service';
|
import { SettlementService } from '../modules/settlement/settlement.service';
|
||||||
import { AlertService } from '../common/alert/alert.service';
|
import { AlertService } from '../common/alert/alert.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 财务对账单定时任务(Asia/Shanghai)
|
* 财务对账单定时任务(Asia/Shanghai)
|
||||||
* - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销,出账日=今天)
|
* - 每日 08:00:酒厂 T+3 期账单(每 3 天出一期,纳入上期 3 天已完成已付订单)+ 近 45 日出账周期核对补漏 + 门店日账单
|
||||||
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
|
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
|
||||||
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003)
|
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003)
|
||||||
*/
|
*/
|
||||||
@@ -23,8 +23,14 @@ export class SettlementScheduler {
|
|||||||
async handleDailyBills() {
|
async handleDailyBills() {
|
||||||
this.logger.log('Daily settlement bills job start');
|
this.logger.log('Daily settlement bills job start');
|
||||||
try {
|
try {
|
||||||
const winery = await this.settlementService.generateWineryBillForDay();
|
const today = startOfShanghaiDay(new Date());
|
||||||
this.logger.log(`Winery bill: ${JSON.stringify(winery)}`);
|
const reconcile = await this.settlementService.reconcileWineryBills({
|
||||||
|
dateFrom: shanghaiYmd(addShanghaiDays(today, -45)),
|
||||||
|
dateTo: shanghaiYmd(today),
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Winery reconcile: days=${reconcile.days} generated=${reconcile.generated} orphans=${reconcile.orphanOrders} cleaned=${reconcile.cleanedNonIssueBills}`,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
|
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import type {
|
|||||||
|
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
import { taskCompletedAtOnStatus, versionStatusTimestamps } from '@dukang/domain';
|
import {
|
||||||
|
resolveTaskStatusFromVersions,
|
||||||
|
taskCompletedAtOnStatus,
|
||||||
|
versionStatusTimestamps,
|
||||||
|
} from '@dukang/domain';
|
||||||
|
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
|
||||||
@@ -530,6 +534,8 @@ export class DevPlanService {
|
|||||||
await this.replaceTaskVersions(id, dto.versionIds);
|
await this.replaceTaskVersions(id, dto.versionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncLinkedTaskStatuses([id]);
|
||||||
|
|
||||||
return this.getTask(id);
|
return this.getTask(id);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -768,8 +774,10 @@ export class DevPlanService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncLinkedTasksForVersion(row.id);
|
||||||
|
|
||||||
if (status === 'RELEASED') {
|
if (status === 'RELEASED') {
|
||||||
await this.cascadeVersionReleased(row.id, row.versionNo);
|
await this.cascadeTicketsPublished(row.id, row.versionNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getVersion(row.id);
|
return this.getVersion(row.id);
|
||||||
@@ -840,15 +848,16 @@ export class DevPlanService {
|
|||||||
|
|
||||||
await this.prisma.devPlanVersion.update({ where: { id }, data });
|
await this.prisma.devPlanVersion.update({ where: { id }, data });
|
||||||
|
|
||||||
if (
|
if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
|
||||||
dto.status === 'RELEASED' &&
|
|
||||||
existing.status !== 'RELEASED'
|
if (dto.status != null || dto.taskIds != null) {
|
||||||
) {
|
await this.syncLinkedTasksForVersion(id);
|
||||||
const versionNo = (dto.versionNo ?? existing.versionNo).trim();
|
|
||||||
await this.cascadeVersionReleased(id, versionNo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
|
if (dto.status === 'RELEASED' && existing.status !== 'RELEASED') {
|
||||||
|
const versionNo = (dto.versionNo ?? existing.versionNo).trim();
|
||||||
|
await this.cascadeTicketsPublished(id, versionNo);
|
||||||
|
}
|
||||||
|
|
||||||
return this.getVersion(id);
|
return this.getVersion(id);
|
||||||
|
|
||||||
@@ -880,11 +889,58 @@ export class DevPlanService {
|
|||||||
|
|
||||||
await this.replaceVersionTasks(versionId, taskIds);
|
await this.replaceVersionTasks(versionId, taskIds);
|
||||||
|
|
||||||
|
await this.syncLinkedTasksForVersion(versionId);
|
||||||
|
|
||||||
return this.getVersion(versionId);
|
return this.getVersion(versionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cascadeVersionReleased(versionId: bigint, versionNo: string) {
|
private async syncLinkedTasksForVersion(versionId: bigint) {
|
||||||
|
const links = await this.prisma.devPlanVersionTask.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
select: { taskId: true },
|
||||||
|
});
|
||||||
|
await this.syncLinkedTaskStatuses(links.map((l) => l.taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncLinkedTaskStatuses(taskIds: bigint[]) {
|
||||||
|
const uniq = [...new Set(taskIds)];
|
||||||
|
if (!uniq.length) return;
|
||||||
|
|
||||||
|
const [tasks, links] = await Promise.all([
|
||||||
|
this.prisma.devPlanTask.findMany({
|
||||||
|
where: { id: { in: uniq } },
|
||||||
|
select: { id: true, status: true, completedAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.devPlanVersionTask.findMany({
|
||||||
|
where: { taskId: { in: uniq } },
|
||||||
|
include: { version: { select: { status: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusesByTask = new Map<string, DevPlanVersionStatus[]>();
|
||||||
|
for (const link of links) {
|
||||||
|
const key = String(link.taskId);
|
||||||
|
const list = statusesByTask.get(key) ?? [];
|
||||||
|
list.push(link.version.status);
|
||||||
|
statusesByTask.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const next = resolveTaskStatusFromVersions(statusesByTask.get(String(task.id)) ?? []);
|
||||||
|
if (!next || next === task.status) continue;
|
||||||
|
const completed = taskCompletedAtOnStatus(task.status, next, task.completedAt);
|
||||||
|
await this.prisma.devPlanTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: next,
|
||||||
|
...(completed ? { completedAt: completed } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cascadeTicketsPublished(versionId: bigint, versionNo: string) {
|
||||||
const links = await this.prisma.devPlanVersionTask.findMany({
|
const links = await this.prisma.devPlanVersionTask.findMany({
|
||||||
where: { versionId },
|
where: { versionId },
|
||||||
select: { taskId: true },
|
select: { taskId: true },
|
||||||
@@ -894,33 +950,26 @@ export class DevPlanService {
|
|||||||
const taskIds = links.map((l) => l.taskId);
|
const taskIds = links.map((l) => l.taskId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
const tasks = await this.prisma.devPlanTask.findMany({
|
||||||
await tx.devPlanTask.updateMany({
|
where: { id: { in: taskIds }, supportTicketId: { not: null } },
|
||||||
where: { id: { in: taskIds }, status: { not: 'RELEASED' } },
|
select: { supportTicketId: true },
|
||||||
data: { status: 'RELEASED', completedAt: now },
|
});
|
||||||
});
|
const ticketIds = [
|
||||||
|
...new Set(
|
||||||
|
tasks
|
||||||
|
.map((t) => t.supportTicketId)
|
||||||
|
.filter((id): id is bigint => id != null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!ticketIds.length) return;
|
||||||
|
|
||||||
const tasks = await tx.devPlanTask.findMany({
|
await this.prisma.commonSupportTicket.updateMany({
|
||||||
where: { id: { in: taskIds }, supportTicketId: { not: null } },
|
where: { id: { in: ticketIds }, status: { not: 'REJECTED' } },
|
||||||
select: { supportTicketId: true },
|
data: {
|
||||||
});
|
status: 'PUBLISHED',
|
||||||
const ticketIds = [
|
releasedVersionNo: versionNo,
|
||||||
...new Set(
|
publishedAt: now,
|
||||||
tasks
|
},
|
||||||
.map((t) => t.supportTicketId)
|
|
||||||
.filter((id): id is bigint => id != null),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (!ticketIds.length) return;
|
|
||||||
|
|
||||||
await tx.commonSupportTicket.updateMany({
|
|
||||||
where: { id: { in: ticketIds }, status: { not: 'REJECTED' } },
|
|
||||||
data: {
|
|
||||||
status: 'PUBLISHED',
|
|
||||||
releasedVersionNo: versionNo,
|
|
||||||
publishedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,6 +1025,8 @@ export class DevPlanService {
|
|||||||
|
|
||||||
await this.addVersionTasks(versionId, taskIds);
|
await this.addVersionTasks(versionId, taskIds);
|
||||||
|
|
||||||
|
await this.syncLinkedTasksForVersion(versionId);
|
||||||
|
|
||||||
return this.getVersion(versionId);
|
return this.getVersion(versionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1299,7 @@ export class DevPlanService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncLinkedTaskStatuses(taskIds);
|
||||||
|
|
||||||
return { updated: taskIds.length };
|
return { updated: taskIds.length };
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ export class AdminPartnersController {
|
|||||||
return this.service.updatePartner(BigInt(id), dto);
|
return this.service.updatePartner(BigInt(id), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.PARTNER_DELETE,
|
||||||
|
refType: 'PARTNER',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.deletePartner(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/assoc')
|
@Get(':id/assoc')
|
||||||
assocSummary(@Param('id') id: string) {
|
assocSummary(@Param('id') id: string) {
|
||||||
return this.assoc.getSummary(BigInt(id));
|
return this.assoc.getSummary(BigInt(id));
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
|
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
|
||||||
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
|
import {
|
||||||
|
assertCanDeleteCityPartner,
|
||||||
|
resolveMaxPartnerCommissionRate,
|
||||||
|
validatePartnerCommissionRates,
|
||||||
|
} 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 { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
@@ -502,6 +506,62 @@ export class AdminPartnersService {
|
|||||||
return serializeBigInt(account);
|
return serializeBigInt(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deletePartner(id: bigint) {
|
||||||
|
const account = await this.prisma.partnerAccount.findFirst({
|
||||||
|
where: { id, ...PRIMARY_WHERE },
|
||||||
|
include: { children: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
if (!account) throw new NotFoundException('开城合伙人不存在');
|
||||||
|
|
||||||
|
const accountIds = [id, ...account.children.map((c) => c.id)];
|
||||||
|
const [storeCount, orderCount, billCount, redeemCount] = await Promise.all([
|
||||||
|
this.prisma.store.count({ where: { partnerAccountId: { in: accountIds } } }),
|
||||||
|
this.prisma.order.count({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ partnerAccountIdAtPay: { in: accountIds } },
|
||||||
|
{ proxyPartnerAccountId: { in: accountIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.partnerBill.count({ where: { partnerAccountId: { in: accountIds } } }),
|
||||||
|
this.prisma.redeemRecord.count({
|
||||||
|
where: { store: { partnerAccountId: { in: accountIds } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const check = assertCanDeleteCityPartner({
|
||||||
|
storeCount,
|
||||||
|
orderCount: orderCount + billCount,
|
||||||
|
redeemCount,
|
||||||
|
});
|
||||||
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.updateMany({
|
||||||
|
where: { assocPartnerAccountId: { in: accountIds } },
|
||||||
|
data: { assocPartnerAccountId: null, assocBoundAt: null },
|
||||||
|
});
|
||||||
|
await tx.partnerUserNote.deleteMany({ where: { partnerAccountId: { in: accountIds } } });
|
||||||
|
await tx.logPartnerAnalytics.deleteMany({ where: { partnerAccountId: { in: accountIds } } });
|
||||||
|
await tx.cityWarehouse.updateMany({
|
||||||
|
where: { partnerAccountId: { in: accountIds } },
|
||||||
|
data: { partnerAccountId: null },
|
||||||
|
});
|
||||||
|
await tx.partnerAccount.updateMany({
|
||||||
|
where: { id: { in: accountIds } },
|
||||||
|
data: { managedWarehouseId: null, assocQrcodeResourceId: null, activityPosterId: null },
|
||||||
|
});
|
||||||
|
const childIds = account.children.map((c) => c.id);
|
||||||
|
if (childIds.length) {
|
||||||
|
await tx.partnerAccount.deleteMany({ where: { id: { in: childIds } } });
|
||||||
|
}
|
||||||
|
await tx.partnerAccount.delete({ where: { id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, message: '城市合伙人已删除' };
|
||||||
|
}
|
||||||
|
|
||||||
async deletePartnerSubAccount(id: bigint) {
|
async deletePartnerSubAccount(id: bigint) {
|
||||||
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
||||||
if (!account) throw new NotFoundException('合伙人账号不存在');
|
if (!account) throw new NotFoundException('合伙人账号不存在');
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export class AdminStoresController {
|
|||||||
return this.service.updateStore(BigInt(id), dto, user.actorId);
|
return this.service.updateStore(BigInt(id), dto, user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({ action: HqOperationAction.STORE_DELETE, refType: 'STORE', refIdParam: 'id' })
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.service.deleteStore(BigInt(id), user.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id/status')
|
@Put(':id/status')
|
||||||
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||||
updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
import {
|
||||||
|
assertCanDeleteStore,
|
||||||
|
isMobilePhone,
|
||||||
|
isStoreContactPhone,
|
||||||
|
STORE_CONTACT_PHONE_HINT,
|
||||||
|
validateBusinessHours,
|
||||||
|
} 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 { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
@@ -241,6 +247,88 @@ export class AdminStoresService {
|
|||||||
return serializeBigInt(store);
|
return serializeBigInt(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteStore(id: bigint, actorId: bigint) {
|
||||||
|
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||||
|
const store = await this.prisma.store.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { bindings: { select: { storeAccountId: true } } },
|
||||||
|
});
|
||||||
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
|
||||||
|
const [redeemCount, pendingCount, payoutCount, billCount, ratingCount, withdrawCount] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.redeemRecord.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.redeemPendingRecord.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storePayout.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storeBill.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storeRating.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storeWithdrawRequest.count({ where: { storeId: id } }),
|
||||||
|
]);
|
||||||
|
const check = assertCanDeleteStore({
|
||||||
|
orderCount: 0,
|
||||||
|
redeemCount:
|
||||||
|
redeemCount + pendingCount + payoutCount + billCount + ratingCount + withdrawCount,
|
||||||
|
});
|
||||||
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
|
|
||||||
|
const accountIds = [...new Set(store.bindings.map((b) => b.storeAccountId))];
|
||||||
|
const exclusiveAccountIds: bigint[] = [];
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const otherBindings = await this.prisma.storeAccountStore.count({
|
||||||
|
where: { storeAccountId: accountId, NOT: { storeId: id } },
|
||||||
|
});
|
||||||
|
if (otherBindings === 0) exclusiveAccountIds.push(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffToDelete: bigint[] = [];
|
||||||
|
if (exclusiveAccountIds.length) {
|
||||||
|
const staff = await this.prisma.storeAccount.findMany({
|
||||||
|
where: { parentAccountId: { in: exclusiveAccountIds }, isPrimary: 0 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const row of staff) {
|
||||||
|
const other = await this.prisma.storeAccountStore.count({
|
||||||
|
where: { storeAccountId: row.id, NOT: { storeId: id } },
|
||||||
|
});
|
||||||
|
if (other > 0) continue;
|
||||||
|
const pending = await this.prisma.redeemPendingRecord.count({
|
||||||
|
where: { storeAccountId: row.id },
|
||||||
|
});
|
||||||
|
if (pending > 0) {
|
||||||
|
throw new BadRequestException('该账号下有关联核销单,禁止删除');
|
||||||
|
}
|
||||||
|
staffToDelete.push(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
|
||||||
|
await tx.commonResource.updateMany({
|
||||||
|
where: { ownerType: 'STORE', ownerId: id },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
await tx.logStoreAnalytics.deleteMany({ where: { storeId: id } });
|
||||||
|
await tx.store.delete({ where: { id } });
|
||||||
|
|
||||||
|
if (staffToDelete.length) {
|
||||||
|
await tx.logStoreAnalytics.deleteMany({ where: { storeAccountId: { in: staffToDelete } } });
|
||||||
|
await tx.storeAccount.deleteMany({ where: { id: { in: staffToDelete } } });
|
||||||
|
}
|
||||||
|
if (exclusiveAccountIds.length) {
|
||||||
|
await tx.logStoreAnalytics.deleteMany({
|
||||||
|
where: { storeAccountId: { in: exclusiveAccountIds } },
|
||||||
|
});
|
||||||
|
await tx.storeAccount.updateMany({
|
||||||
|
where: { parentAccountId: { in: exclusiveAccountIds } },
|
||||||
|
data: { parentAccountId: null },
|
||||||
|
});
|
||||||
|
await tx.storeAccount.deleteMany({ where: { id: { in: exclusiveAccountIds } } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, message: '门店及门店账号已删除' };
|
||||||
|
}
|
||||||
|
|
||||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
|
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
|
||||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import { parseShanghaiYmd } from '@dukang/domain';
|
||||||
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
@@ -464,8 +465,23 @@ export class AdminWineryBillController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('generate')
|
@Post('generate')
|
||||||
generate() {
|
generate(@Body() body?: { date?: string }) {
|
||||||
return this.settlementService.generateWineryBillForDay();
|
const anchor = body?.date?.trim() ? parseShanghaiYmd(body.date.trim()) : new Date();
|
||||||
|
if (body?.date?.trim() && Number.isNaN(anchor.getTime())) {
|
||||||
|
throw new BadRequestException('日期格式无效,请使用 YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
return this.settlementService.generateWineryBillForDay(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reconcile')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.WINERY_BILL_RECONCILE,
|
||||||
|
refType: 'WINERY_BILL',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
reconcile(@Body() body?: { dateFrom?: string; dateTo?: string }) {
|
||||||
|
return this.settlementService.reconcileWineryBills(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('batch-confirm')
|
@Post('batch-confirm')
|
||||||
|
|||||||
@@ -5,27 +5,31 @@ import {
|
|||||||
DEFAULT_XFX_LOGISTICS_PRICING,
|
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||||
PAYMENT_PROOF_IMAGE_MAX_COUNT,
|
PAYMENT_PROOF_IMAGE_MAX_COUNT,
|
||||||
WINERY_SETTLEMENT_LAG_DAYS,
|
WINERY_BILL_EPOCH_YMD,
|
||||||
|
WINERY_SETTLEMENT_PERIOD_DAYS,
|
||||||
WINERY_SETTLEMENT_RATE,
|
WINERY_SETTLEMENT_RATE,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
addShanghaiDays,
|
addShanghaiDays,
|
||||||
calcLogisticsFeeByBottles,
|
calcLogisticsFeeByBottles,
|
||||||
calcRedeemSettleAmount,
|
calcRedeemSettleAmount,
|
||||||
|
isWineryIssueDay,
|
||||||
|
listWineryIssueDays,
|
||||||
parseShanghaiYmd,
|
parseShanghaiYmd,
|
||||||
pickPayoutsForWithdrawAmount,
|
pickPayoutsForWithdrawAmount,
|
||||||
previousShanghaiMonth,
|
previousShanghaiMonth,
|
||||||
resolveSettlementRate,
|
resolveSettlementRate,
|
||||||
shanghaiLaggedIssueWindow,
|
|
||||||
shanghaiMonthLastInstant,
|
shanghaiMonthLastInstant,
|
||||||
shanghaiMonthRange,
|
shanghaiMonthRange,
|
||||||
shanghaiPeriodYmds,
|
shanghaiPeriodYmds,
|
||||||
shanghaiT1DayWindow,
|
shanghaiT1DayWindow,
|
||||||
|
shanghaiWineryPeriodWindow,
|
||||||
shanghaiYearMonth,
|
shanghaiYearMonth,
|
||||||
shanghaiYmd,
|
shanghaiYmd,
|
||||||
startOfShanghaiDay,
|
startOfShanghaiDay,
|
||||||
sumUnbilledPayoutAmount,
|
sumUnbilledPayoutAmount,
|
||||||
validateStoreWithdraw,
|
validateStoreWithdraw,
|
||||||
|
wineryIssueDateFromCompletedAt,
|
||||||
type LogisticsPricingRule,
|
type LogisticsPricingRule,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
@@ -236,19 +240,25 @@ export class SettlementService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 酒厂账单日改为出账当天(不再存 T+3 完成日) */
|
/**
|
||||||
|
* 一次性:旧「billDate = 完成日」改为出账当天。
|
||||||
|
* 已是 T+3 周期出账日的账单禁止再按 createdAt 挪日,否则补生成的历史期账单会被改成今天。
|
||||||
|
*/
|
||||||
private async realignWineryBillIssueDates(): Promise<number> {
|
private async realignWineryBillIssueDates(): Promise<number> {
|
||||||
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
|
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
|
||||||
SELECT GET_LOCK('winery_bill_issue_date_align', 5) AS acquired
|
SELECT GET_LOCK('winery_bill_issue_date_align', 5) AS acquired
|
||||||
`;
|
`;
|
||||||
if (!Number(locked[0]?.acquired)) return 0;
|
if (!Number(locked[0]?.acquired)) return 0;
|
||||||
try {
|
try {
|
||||||
|
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
|
||||||
|
const epochYmd = WINERY_BILL_EPOCH_YMD;
|
||||||
const bills = await this.prisma.wineryBill.findMany({
|
const bills = await this.prisma.wineryBill.findMany({
|
||||||
select: { id: true, billDate: true, createdAt: true },
|
select: { id: true, billDate: true, createdAt: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
let shifted = 0;
|
let shifted = 0;
|
||||||
for (const b of bills) {
|
for (const b of bills) {
|
||||||
|
if (isWineryIssueDay(b.billDate, periodDays, epochYmd)) continue;
|
||||||
const issue = startOfShanghaiDay(b.createdAt);
|
const issue = startOfShanghaiDay(b.createdAt);
|
||||||
if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue;
|
if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue;
|
||||||
const clash = await this.prisma.wineryBill.findUnique({ where: { billDate: issue } });
|
const clash = await this.prisma.wineryBill.findUnique({ where: { billDate: issue } });
|
||||||
@@ -2201,10 +2211,58 @@ export class SettlementService implements OnModuleInit {
|
|||||||
|
|
||||||
// ─── Winery bills ────────────────────────────────────
|
// ─── Winery bills ────────────────────────────────────
|
||||||
|
|
||||||
async generateWineryBillForDay(anchor = new Date()) {
|
/** 酒厂账单纳入的配送类型:同城 / 跨城 / 现场提货 */
|
||||||
const { start, end, billDate } = shanghaiLaggedIssueWindow(anchor, WINERY_SETTLEMENT_LAG_DAYS);
|
private static readonly WINERY_DELIVERY_TYPES = ['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'] as const;
|
||||||
const rate = WINERY_SETTLEMENT_RATE;
|
|
||||||
|
/**
|
||||||
|
* 历史现场提货单支付即 COMPLETED 但未写 completedAt,补齐为 paidAt,否则核对补生成捞不到。
|
||||||
|
*/
|
||||||
|
private async backfillMissingCompletedAt() {
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
UPDATE user_order
|
||||||
|
SET completed_at = paid_at
|
||||||
|
WHERE status = 'COMPLETED'
|
||||||
|
AND pay_status = 'PAID'
|
||||||
|
AND completed_at IS NULL
|
||||||
|
AND paid_at IS NOT NULL
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 期窗口内已付已完成订单(含同城/跨城/现场提货;completedAt 优先,缺省用 paidAt) */
|
||||||
|
private wineryEligibleOrderWhere(start: Date, end: Date): Prisma.OrderWhereInput {
|
||||||
|
return {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
payStatus: 'PAID',
|
||||||
|
deliveryType: { in: [...SettlementService.WINERY_DELIVERY_TYPES] },
|
||||||
|
OR: [
|
||||||
|
{ completedAt: { gte: start, lt: end } },
|
||||||
|
{ completedAt: null, paidAt: { gte: start, lt: end } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成/重算某一出账日的酒厂对账单(T+3 = 每 3 天出一期)。
|
||||||
|
* - 仅在周期出账日生成:相对 epoch 每 3 天一张
|
||||||
|
* - 纳入上一期 3 个自然日内完成的同城/跨城/现场提货已付订单(含实付/应付为 0)
|
||||||
|
* - 出账日无订单也出账(无需打款),保证每隔三天有账单
|
||||||
|
* - 已打款账单不回刷;未打款账单可重算;冲突明细从其他未打款账单迁入
|
||||||
|
*/
|
||||||
|
async generateWineryBillForDay(anchor = new Date(), opts?: { notify?: boolean }) {
|
||||||
|
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
|
||||||
|
const epochYmd = WINERY_BILL_EPOCH_YMD;
|
||||||
|
const billDate = startOfShanghaiDay(anchor);
|
||||||
const issueYmd = shanghaiYmd(billDate);
|
const issueYmd = shanghaiYmd(billDate);
|
||||||
|
const today = startOfShanghaiDay(new Date());
|
||||||
|
if (billDate.getTime() > today.getTime()) {
|
||||||
|
return { billDate: issueYmd, skipped: true, reason: '出账日未到' };
|
||||||
|
}
|
||||||
|
if (!isWineryIssueDay(billDate, periodDays, epochYmd)) {
|
||||||
|
return { billDate: issueYmd, skipped: true, reason: '非出账周期日(T+3 每 3 天出账)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = shanghaiWineryPeriodWindow(billDate, periodDays);
|
||||||
|
const rate = WINERY_SETTLEMENT_RATE;
|
||||||
|
|
||||||
const existing =
|
const existing =
|
||||||
(await this.prisma.wineryBill.findUnique({ where: { billDate } })) ??
|
(await this.prisma.wineryBill.findUnique({ where: { billDate } })) ??
|
||||||
@@ -2215,21 +2273,31 @@ export class SettlementService implements OnModuleInit {
|
|||||||
return { billDate: issueYmd, skipped: true, reason: '已打款' };
|
return { billDate: issueYmd, skipped: true, reason: '已打款' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// T+3:纳入「今天 − lagDays」当天完成的同城/跨城订单;billDate = 出账日(今天)
|
// 手工单日出账时也补齐缺 completedAt 的现场单
|
||||||
const orders = await this.prisma.order.findMany({
|
await this.backfillMissingCompletedAt();
|
||||||
where: {
|
|
||||||
status: 'COMPLETED',
|
// 期窗口 [出账日−3, 出账日) 内:同城 / 跨城 / 现场提货
|
||||||
payStatus: 'PAID',
|
const candidates = await this.prisma.order.findMany({
|
||||||
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
|
where: this.wineryEligibleOrderWhere(start, end),
|
||||||
completedAt: { gte: start, lt: end },
|
|
||||||
},
|
|
||||||
include: { city: { select: { name: true } } },
|
include: { city: { select: { name: true } } },
|
||||||
orderBy: { completedAt: 'asc' },
|
orderBy: [{ completedAt: 'asc' }, { paidAt: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (orders.length === 0 && !existing) {
|
// 已打款账单上的明细不可再迁;其余订单一律纳入本账
|
||||||
return { billDate: issueYmd, skipped: true, reason: '无订单' };
|
const candidateIds = candidates.map((o) => o.id);
|
||||||
|
const paidLockedIds = new Set<string>();
|
||||||
|
if (candidateIds.length) {
|
||||||
|
const locked = await this.prisma.wineryBillItem.findMany({
|
||||||
|
where: {
|
||||||
|
orderId: { in: candidateIds },
|
||||||
|
wineryBill: { status: 'PAID' },
|
||||||
|
...(existing ? { wineryBillId: { not: existing.id } } : {}),
|
||||||
|
},
|
||||||
|
select: { orderId: true },
|
||||||
|
});
|
||||||
|
for (const row of locked) paidLockedIds.add(row.orderId.toString());
|
||||||
}
|
}
|
||||||
|
const orders = candidates.filter((o) => !paidLockedIds.has(o.id.toString()));
|
||||||
|
|
||||||
const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0));
|
const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0));
|
||||||
const wineryAmount = round2(orderAmount * rate);
|
const wineryAmount = round2(orderAmount * rate);
|
||||||
@@ -2262,7 +2330,22 @@ export class SettlementService implements OnModuleInit {
|
|||||||
await tx.wineryBillItem.deleteMany({ where: { wineryBillId: header.id } });
|
await tx.wineryBillItem.deleteMany({ where: { wineryBillId: header.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从未打款的其他账单迁出冲突明细,避免 orderId 唯一约束失败导致整日对不上
|
||||||
|
const affectedBillIds = new Set<bigint>();
|
||||||
if (orders.length > 0) {
|
if (orders.length > 0) {
|
||||||
|
const conflicts = await tx.wineryBillItem.findMany({
|
||||||
|
where: {
|
||||||
|
orderId: { in: orders.map((o) => o.id) },
|
||||||
|
wineryBillId: { not: header.id },
|
||||||
|
wineryBill: { status: 'UNPAID' },
|
||||||
|
},
|
||||||
|
select: { id: true, wineryBillId: true },
|
||||||
|
});
|
||||||
|
for (const c of conflicts) affectedBillIds.add(c.wineryBillId);
|
||||||
|
if (conflicts.length) {
|
||||||
|
await tx.wineryBillItem.deleteMany({ where: { id: { in: conflicts.map((c) => c.id) } } });
|
||||||
|
}
|
||||||
|
|
||||||
await tx.wineryBillItem.createMany({
|
await tx.wineryBillItem.createMany({
|
||||||
data: orders.map((o) => ({
|
data: orders.map((o) => ({
|
||||||
wineryBillId: header.id,
|
wineryBillId: header.id,
|
||||||
@@ -2271,50 +2354,189 @@ export class SettlementService implements OnModuleInit {
|
|||||||
deliveryType: o.deliveryType,
|
deliveryType: o.deliveryType,
|
||||||
payAmount: o.payAmount,
|
payAmount: o.payAmount,
|
||||||
wineryAmount: round2(Number(o.payAmount) * rate),
|
wineryAmount: round2(Number(o.payAmount) * rate),
|
||||||
// 明细仍保留支付时间;账期窗口按 completedAt 归属
|
// 明细时间:支付时间优先;账期归属用 completedAt(现场提货已与支付同时写入)
|
||||||
paidAt: o.paidAt ?? o.completedAt!,
|
paidAt: o.paidAt ?? o.completedAt!,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const otherId of affectedBillIds) {
|
||||||
|
await this.recalcWineryBillHeader(tx, otherId, rate);
|
||||||
|
}
|
||||||
|
|
||||||
return header;
|
return header;
|
||||||
});
|
});
|
||||||
|
|
||||||
const period = issueYmd;
|
const period = issueYmd;
|
||||||
const wineryBank = await loadWineryBankConfig(this.prisma);
|
if (opts?.notify !== false && wineryAmount > 0) {
|
||||||
const bankParts = wecomBankParts(wineryBank);
|
const wineryBank = await loadWineryBankConfig(this.prisma);
|
||||||
const cityNames = joinLimited(
|
const bankParts = wecomBankParts(wineryBank);
|
||||||
orders.map((o) => o.city?.name || ''),
|
const cityNames = joinLimited(
|
||||||
'城',
|
orders.map((o) => o.city?.name || ''),
|
||||||
);
|
'城',
|
||||||
const partnerNames = await this.partnerDashLabels(orders.map((o) => o.partnerAccountIdAtPay));
|
);
|
||||||
void this.wecomPush.dispatchEvent(
|
const partnerNames = await this.partnerDashLabels(orders.map((o) => o.partnerAccountIdAtPay));
|
||||||
'finance.winery_bill',
|
void this.wecomPush.dispatchEvent(
|
||||||
{
|
'finance.winery_bill',
|
||||||
period,
|
{
|
||||||
billCount: '1',
|
period,
|
||||||
totalAmount: wineryAmount.toFixed(2),
|
billCount: '1',
|
||||||
cityNames,
|
totalAmount: wineryAmount.toFixed(2),
|
||||||
partnerNames,
|
cityNames,
|
||||||
orderCount: String(orders.length),
|
partnerNames,
|
||||||
orderAmount: orderAmount.toFixed(2),
|
orderCount: String(orders.length),
|
||||||
rate: formatPercent(rate),
|
orderAmount: orderAmount.toFixed(2),
|
||||||
amount: wineryAmount.toFixed(2),
|
rate: formatPercent(rate),
|
||||||
payee: bankParts.payee,
|
amount: wineryAmount.toFixed(2),
|
||||||
accountNo: bankParts.accountNo,
|
payee: bankParts.payee,
|
||||||
bankBranch: bankParts.bankBranch,
|
accountNo: bankParts.accountNo,
|
||||||
bankAccount: formatWecomBankAccount(wineryBank),
|
bankBranch: bankParts.bankBranch,
|
||||||
},
|
bankAccount: formatWecomBankAccount(wineryBank),
|
||||||
{ handlePath: '/finance/winery-bills' },
|
},
|
||||||
);
|
{ handlePath: '/finance/winery-bills' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
billDate: period,
|
billDate: period,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
bill,
|
bill,
|
||||||
|
orderCount: orders.length,
|
||||||
|
orderAmount,
|
||||||
|
wineryAmount,
|
||||||
|
lockedOnPaid: paidLockedIds.size,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按明细重算酒厂账单表头(应付为 0 保留账单,展示为无需打款) */
|
||||||
|
private async recalcWineryBillHeader(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
wineryBillId: bigint,
|
||||||
|
rate = WINERY_SETTLEMENT_RATE,
|
||||||
|
) {
|
||||||
|
const items = await tx.wineryBillItem.findMany({ where: { wineryBillId } });
|
||||||
|
const orderAmount = round2(items.reduce((s, i) => s + Number(i.payAmount), 0));
|
||||||
|
const wineryAmount = round2(orderAmount * rate);
|
||||||
|
await tx.wineryBill.update({
|
||||||
|
where: { id: wineryBillId },
|
||||||
|
data: {
|
||||||
|
orderCount: items.length,
|
||||||
|
orderAmount,
|
||||||
|
wineryRate: rate,
|
||||||
|
wineryAmount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按出账日区间核对补生成酒厂账单:只处理 T+3 周期出账日,捞回未入账的同城/跨城/现场提货订单,并清理误生成的非周期日空账单。
|
||||||
|
*/
|
||||||
|
async reconcileWineryBills(body?: { dateFrom?: string; dateTo?: string }) {
|
||||||
|
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
|
||||||
|
const epochYmd = WINERY_BILL_EPOCH_YMD;
|
||||||
|
const today = startOfShanghaiDay(new Date());
|
||||||
|
const to = body?.dateTo ? parseShanghaiYmd(body.dateTo) : today;
|
||||||
|
const from = body?.dateFrom
|
||||||
|
? parseShanghaiYmd(body.dateFrom)
|
||||||
|
: addShanghaiDays(today, -60);
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||||
|
throw new BadRequestException('日期格式无效,请使用 YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
if (from.getTime() > to.getTime()) {
|
||||||
|
throw new BadRequestException('开始日期不能晚于结束日期');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先补历史现场提货等缺 completedAt 的已完成单,再按期入账
|
||||||
|
await this.backfillMissingCompletedAt();
|
||||||
|
|
||||||
|
const endIssue = to.getTime() > today.getTime() ? today : to;
|
||||||
|
const startIssue = from.getTime() > endIssue.getTime() ? endIssue : from;
|
||||||
|
|
||||||
|
const issueYmids = new Set(
|
||||||
|
listWineryIssueDays(startIssue, endIssue, periodDays, epochYmd).map(shanghaiYmd),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 捞仍未入账的孤儿订单(同城/跨城/现场提货),按其完成日归属到对应出账日
|
||||||
|
const orphanLookbackStart = addShanghaiDays(startIssue, -periodDays - 90);
|
||||||
|
const orphanLookbackEnd = addShanghaiDays(today, 1);
|
||||||
|
const windowOrders = await this.prisma.order.findMany({
|
||||||
|
where: this.wineryEligibleOrderWhere(orphanLookbackStart, orphanLookbackEnd),
|
||||||
|
select: { id: true, completedAt: true, paidAt: true },
|
||||||
|
});
|
||||||
|
const billedSet = new Set(
|
||||||
|
windowOrders.length === 0
|
||||||
|
? []
|
||||||
|
: (
|
||||||
|
await this.prisma.wineryBillItem.findMany({
|
||||||
|
where: { orderId: { in: windowOrders.map((o) => o.id) } },
|
||||||
|
select: { orderId: true },
|
||||||
|
})
|
||||||
|
).map((r) => r.orderId.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let orphanCount = 0;
|
||||||
|
for (const o of windowOrders) {
|
||||||
|
const attributedAt = o.completedAt ?? o.paidAt;
|
||||||
|
if (!attributedAt || billedSet.has(o.id.toString())) continue;
|
||||||
|
const issue = wineryIssueDateFromCompletedAt(attributedAt, periodDays, epochYmd);
|
||||||
|
if (issue.getTime() > today.getTime()) continue;
|
||||||
|
issueYmids.add(shanghaiYmd(issue));
|
||||||
|
orphanCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...issueYmids].sort();
|
||||||
|
const results: Array<{
|
||||||
|
billDate: string;
|
||||||
|
skipped: boolean;
|
||||||
|
reason?: string;
|
||||||
|
orderCount?: number;
|
||||||
|
wineryAmount?: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const ymd of sorted) {
|
||||||
|
const r = await this.generateWineryBillForDay(parseShanghaiYmd(ymd), { notify: false });
|
||||||
|
results.push({
|
||||||
|
billDate: String(r.billDate),
|
||||||
|
skipped: Boolean(r.skipped),
|
||||||
|
reason: 'reason' in r ? String(r.reason ?? '') : undefined,
|
||||||
|
orderCount: 'orderCount' in r ? Number(r.orderCount ?? 0) : undefined,
|
||||||
|
wineryAmount: 'wineryAmount' in r ? Number(r.wineryAmount ?? 0) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理旧「每日出账」留下的非周期日出账单(明细已迁走后为空)
|
||||||
|
let cleaned = 0;
|
||||||
|
const strayBills = await this.prisma.wineryBill.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'UNPAID',
|
||||||
|
billDate: { gte: startIssue, lte: endIssue },
|
||||||
|
},
|
||||||
|
include: { _count: { select: { items: true } } },
|
||||||
|
});
|
||||||
|
for (const b of strayBills) {
|
||||||
|
if (isWineryIssueDay(b.billDate, periodDays, epochYmd)) continue;
|
||||||
|
if (b._count.items > 0) {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.recalcWineryBillHeader(tx, b.id);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.prisma.wineryBill.delete({ where: { id: b.id } });
|
||||||
|
cleaned += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom: shanghaiYmd(startIssue),
|
||||||
|
dateTo: shanghaiYmd(endIssue),
|
||||||
|
days: results.length,
|
||||||
|
generated: results.filter((r) => !r.skipped).length,
|
||||||
|
skipped: results.filter((r) => r.skipped).length,
|
||||||
|
orphanOrders: orphanCount,
|
||||||
|
cleanedNonIssueBills: cleaned,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async listAdminWineryBills(query: {
|
async listAdminWineryBills(query: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -2469,7 +2691,13 @@ export class SettlementService implements OnModuleInit {
|
|||||||
csvEscape(b.billNo),
|
csvEscape(b.billNo),
|
||||||
shanghaiYmd(b.billDate),
|
shanghaiYmd(b.billDate),
|
||||||
csvEscape(item.orderNo),
|
csvEscape(item.orderNo),
|
||||||
item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType,
|
item.deliveryType === 'LOCAL'
|
||||||
|
? '同城'
|
||||||
|
: item.deliveryType === 'CROSS_CITY'
|
||||||
|
? '跨城'
|
||||||
|
: item.deliveryType === 'ON_SITE_PICKUP'
|
||||||
|
? '现场提货'
|
||||||
|
: item.deliveryType,
|
||||||
Number(item.payAmount),
|
Number(item.payAmount),
|
||||||
Number(b.wineryRate),
|
Number(b.wineryRate),
|
||||||
Number(item.wineryAmount),
|
Number(item.wineryAmount),
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ export class TradeService {
|
|||||||
status: toStatus,
|
status: toStatus,
|
||||||
payStatus: 'PAID',
|
payStatus: 'PAID',
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
|
...(toStatus === 'COMPLETED' ? { completedAt: now } : {}),
|
||||||
payExternalNo: externalNo,
|
payExternalNo: externalNo,
|
||||||
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
||||||
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
||||||
@@ -636,6 +637,7 @@ export class TradeService {
|
|||||||
status: toStatus,
|
status: toStatus,
|
||||||
payStatus: 'PAID',
|
payStatus: 'PAID',
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
|
...(toStatus === 'COMPLETED' ? { completedAt: now } : {}),
|
||||||
payExternalNo: params.transactionId,
|
payExternalNo: params.transactionId,
|
||||||
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
||||||
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
||||||
@@ -2651,6 +2653,7 @@ export class TradeService {
|
|||||||
status: toStatus,
|
status: toStatus,
|
||||||
payStatus: 'PAID',
|
payStatus: 'PAID',
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
|
...(toStatus === 'COMPLETED' ? { completedAt: now } : {}),
|
||||||
payExternalNo: externalNo,
|
payExternalNo: externalNo,
|
||||||
partnerAccountIdAtPay: order.partnerAccountIdAtPay,
|
partnerAccountIdAtPay: order.partnerAccountIdAtPay,
|
||||||
orderCommissionRateAtPay: order.orderCommissionRateAtPay,
|
orderCommissionRateAtPay: order.orderCommissionRateAtPay,
|
||||||
|
|||||||
Reference in New Issue
Block a user