diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index bcaf6c4..b9f1799 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -62,9 +62,11 @@ export const CITY_STATUS_LABELS: Record = { }; export const PARTNER_BILL_STATUS_LABELS: Record = { - DRAFT: '草稿', - CONFIRMED: '已确认', - PAID: '已结算', + PENDING_REVIEW: '待审核', + AWAITING_CONFIRM: '待合伙人确认', + UNPAID: '未打款', + PAID: '已打款', + REJECTED: '已驳回', }; export const MEDIA_TYPE_LABELS: Record = { diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index cccd121..7c4cda7 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -40,9 +40,16 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'TICKET_REJECT', label: '工单驳回' }, { value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' }, { value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' }, + { value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' }, + { value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' }, { value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' }, + { value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' }, + { value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' }, { value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' }, { value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' }, + { value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' }, + { value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' }, + { value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' }, ] as const; export const HQ_OPERATION_ACTION_LABELS: Record = Object.fromEntries( diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index 8680957..40f1b74 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -38,15 +38,17 @@ type Row = { type PartnerOption = { id: string; companyName: string; phone?: string }; const STATUS_LABELS: Record = { - DRAFT: '待合伙人确认', - CONFIRMED: '待打款审核', + PENDING_REVIEW: '待审核', + AWAITING_CONFIRM: '待合伙人确认', + UNPAID: '未打款', PAID: '已打款', REJECTED: '已驳回', }; const STATUS_COLORS: Record = { - DRAFT: 'default', - CONFIRMED: 'orange', + PENDING_REVIEW: 'gold', + AWAITING_CONFIRM: 'blue', + UNPAID: 'red', PAID: 'green', REJECTED: 'red', }; @@ -74,6 +76,7 @@ export default function PartnerBillsPage() { const [rejectTarget, setRejectTarget] = useState(null); const [rejectForm] = Form.useForm(); const [rejecting, setRejecting] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); useEffect(() => { void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -81,10 +84,10 @@ export default function PartnerBillsPage() { .catch(() => {}); }, []); - async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) { + async function generateBill(values: { partnerId?: string; month: Dayjs }) { const year = values.month.year(); const month = values.month.month() + 1; - if (values.all || !values.partnerId) { + if (!values.partnerId) { const result = await request<{ success: number; failed: number; total: number }>( '/admin/partner-bills/generate-all', { method: 'POST', body: JSON.stringify({ year, month }) }, @@ -95,31 +98,71 @@ export default function PartnerBillsPage() { method: 'POST', body: JSON.stringify({ partnerId: values.partnerId, year, month }), }); - message.success('账单已生成'); + message.success('账单已生成(待审核)'); } setGenOpen(false); reload(); } - async function confirmBill(id: string) { - await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); - message.success('已代确认,进入待打款审核'); - reload(); + function sendBills(ids: string[]) { + Modal.confirm({ + title: '发送给合伙人?', + content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`, + okText: '确认发送', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' }); + } else { + await request('/admin/partner-bills/batch-send', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已发送'); + setSelectedKeys([]); + reload(); + }, + }); } - async function markPaid(id: string) { - await request(`/admin/partner-bills/${id}/mark-paid`, { - method: 'POST', - body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), + function markPaid(ids: string[], amountHint?: number) { + Modal.confirm({ + title: '确认打款?', + content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`, + okText: '确认打款', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { + method: 'POST', + body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), + }); + } else { + await request('/admin/partner-bills/batch-mark-paid', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已标记打款'); + setSelectedKeys([]); + reload(); + }, }); - message.success('已通过并标记打款'); - reload(); } function openReject(row: Row) { - setRejectTarget(row); - rejectForm.resetFields(); - setRejectOpen(true); + Modal.confirm({ + title: '驳回该账单?', + content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。', + okText: '继续填写理由', + cancelText: '取消', + onOk: () => { + setRejectTarget(row); + rejectForm.resetFields(); + setRejectOpen(true); + }, + }); } async function submitReject(values: { reason: string }) { @@ -130,7 +173,7 @@ export default function PartnerBillsPage() { method: 'POST', body: JSON.stringify({ reason: values.reason.trim() }), }); - message.success('已驳回打款申请'); + message.success('已驳回'); setRejectOpen(false); setRejectTarget(null); reload(); @@ -158,6 +201,10 @@ export default function PartnerBillsPage() { const summary = data?.summary; const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; + const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); + const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED'); + const canPay = selectedRows.filter((r) => r.status === 'UNPAID'); + const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0); const columns: ColumnsType = [ { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, @@ -194,7 +241,7 @@ export default function PartnerBillsPage() { { title: '状态', dataIndex: 'status', - width: 120, + width: 130, render: (s, row) => ( {STATUS_LABELS[s] || s} @@ -212,21 +259,26 @@ export default function PartnerBillsPage() { fixed: 'right', render: (_, row) => ( - {row.status === 'DRAFT' && ( - )} - {row.status === 'CONFIRMED' && ( + {row.status === 'UNPAID' && ( <> - )} + {row.status === 'AWAITING_CONFIRM' && ( + + )} ), }, @@ -239,7 +291,7 @@ export default function PartnerBillsPage() { 合伙人账单 - T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由) + 每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款 @@ -287,7 +339,7 @@ export default function PartnerBillsPage() { ({ value, label }))} + options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> - + @@ -248,6 +259,16 @@ export default function StoreBillsPage() { 导出 Excel + + + ({ disabled: r.status !== 'UNPAID' }), + }} + scroll={{ x: 1200 }} pagination={{ current: page, pageSize, @@ -269,28 +295,44 @@ export default function StoreBillsPage() { }} /> - setDrawerOpen(false)}> + setDrawerOpen(false)} width={520}> {detail && ( - - - {String((detail.store as { name?: string })?.name ?? '—')} - - - {String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')} - - ¥{String(detail.redeemAmount)} - ¥{String(detail.payoutAmount)} - - {detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'} - - - {PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)} - - {fmtTime(String(detail.expectedPayAt))} - - {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} - - + <> + + {String(detail.billNo)} + {String(detail.billDate || '').slice(0, 10)} + ¥{Number(detail.payoutAmount ?? 0).toFixed(2)} + {STATUS_LABELS[String(detail.status)] || String(detail.status)} + + {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} + + + + 核销明细 + +
>) || []} + columns={[ + { + title: '核销单号', + render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'), + }, + { + title: '金额', + dataIndex: 'payoutAmount', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + render: (s) => (s === 'PAID' ? '已打款' : '未打款'), + }, + ]} + /> + )} diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index 0e56960..a046c87 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -3,7 +3,10 @@ import { Button, Card, DatePicker, + Descriptions, + Drawer, Form, + Modal, Select, Space, Statistic, @@ -21,17 +24,34 @@ import { request } from '../lib/api'; import { useAdminList } from '../lib/useAdminList'; type Row = { - orderId: string; - orderNo: string; - deliveryType: string; - cityName?: string; - receiverCity?: string; - payAmount: number; + id: string; + billNo: string; + billDate: string; + orderCount: number; + orderAmount: number; wineryRate: number; wineryAmount: number; + status: string; + paidAt?: string | null; +}; + +type BillItem = { + id: string; + orderNo: string; + deliveryType: string; + payAmount: number; + wineryAmount: number; paidAt: string; - productName?: string; - quantity?: number; +}; + +const STATUS_LABELS: Record = { + UNPAID: '未打款', + PAID: '已打款', +}; + +const STATUS_COLORS: Record = { + UNPAID: 'red', + PAID: 'green', }; const DELIVERY_LABELS: Record = { @@ -42,29 +62,64 @@ const DELIVERY_LABELS: Record = { export default function WineryBillsPage() { const [form] = Form.useForm(); const [filters, setFilters] = useState>({}); - const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/winery-bills', () => { const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); if (filters.year) qs.set('year', filters.year); if (filters.month) qs.set('month', filters.month); - if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType); + if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); + if (filters.dateTo) qs.set('dateTo', filters.dateTo); return qs; }, [filters], ); const [exporting, setExporting] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + function confirmPay(ids: string[], amountHint?: number) { + Modal.confirm({ + title: '确认打款?', + content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`, + okText: '确认打款', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' }); + } else { + await request('/admin/winery-bills/batch-confirm', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已确认打款'); + setSelectedKeys([]); + reload(); + }, + }); + } + + async function openDetail(id: string) { + const d = await request(`/admin/winery-bills/${id}`); + setDetail(d); + setDrawerOpen(true); + } async function exportExcel() { setExporting(true); try { const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); if (filters.year) qs.set('year', filters.year); if (filters.month) qs.set('month', filters.month); - if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType); + if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); + if (filters.dateTo) qs.set('dateTo', filters.dateTo); const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`); const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; - downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`); + downloadExcelCsv(result.csv, `酒厂对账单_${suffix}.csv`); message.success(`已导出 ${result.count} 条`); } finally { setExporting(false); @@ -73,23 +128,22 @@ export default function WineryBillsPage() { const summary = data?.summary; const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100); + const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); + const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0); const columns: ColumnsType = [ - { title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true }, + { title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, { - title: '配送类型', - dataIndex: 'deliveryType', - width: 90, - render: (v) => {DELIVERY_LABELS[v] || v}, - }, - { title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' }, - { title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' }, - { title: '商品', dataIndex: 'productName', width: 140, ellipsis: true }, - { title: '数量', dataIndex: 'quantity', width: 70 }, - { - title: '酒单实付', - dataIndex: 'payAmount', + title: '账单日', + dataIndex: 'billDate', width: 110, + render: (v) => String(v || '').slice(0, 10), + }, + { title: '订单数', dataIndex: 'orderCount', width: 80 }, + { + title: '酒单实付合计', + dataIndex: 'orderAmount', + width: 120, render: (v) => `¥${Number(v).toFixed(2)}`, }, { @@ -105,10 +159,27 @@ export default function WineryBillsPage() { render: (v) => `¥${Number(v).toFixed(2)}`, }, { - title: '支付时间', - dataIndex: 'paidAt', - width: 170, - render: (v) => (v ? fmtTime(v) : '—'), + title: '状态', + dataIndex: 'status', + width: 90, + render: (s) => {STATUS_LABELS[s] || s}, + }, + { + title: '操作', + width: 160, + fixed: 'right', + render: (_, row) => ( + + + {row.status === 'UNPAID' && ( + + )} + + ), }, ]; @@ -116,19 +187,19 @@ export default function WineryBillsPage() {
- 酒厂账单 + 酒厂对账单 - T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定) + 每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细 {summary && ( - + - + )} @@ -137,28 +208,30 @@ export default function WineryBillsPage() { form={form} layout="inline" style={{ marginBottom: 16 }} - onFinish={(v: { month?: Dayjs; deliveryType?: string }) => { + onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => { setFilters({ + status: v.status || '', year: v.month ? String(v.month.year()) : '', month: v.month ? String(v.month.month() + 1) : '', - deliveryType: v.deliveryType || '', + dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', + dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '', }); setPage(1); }} > - - - - +
({ disabled: r.status !== 'UNPAID' }), + }} + scroll={{ x: 1100 }} pagination={{ current: page, pageSize, @@ -200,6 +287,56 @@ export default function WineryBillsPage() { }, }} /> + + setDrawerOpen(false)} width={640}> + {detail && ( + <> + + {detail.billNo} + {String(detail.billDate).slice(0, 10)} + ¥{Number(detail.wineryAmount).toFixed(2)} + {STATUS_LABELS[detail.status] || detail.status} + {detail.paidAt ? fmtTime(detail.paidAt) : '—'} + + + 订单明细 + +
DELIVERY_LABELS[v] || v, + }, + { + title: '实付', + dataIndex: 'payAmount', + width: 90, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '酒厂应付', + dataIndex: 'wineryAmount', + width: 90, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '支付时间', + dataIndex: 'paidAt', + width: 150, + render: (v) => (v ? fmtTime(v) : '—'), + }, + ]} + /> + + )} + ); } diff --git a/apps/h5-partner/src/pages/BillsPage.tsx b/apps/h5-partner/src/pages/BillsPage.tsx index 61c4d27..f260e33 100644 --- a/apps/h5-partner/src/pages/BillsPage.tsx +++ b/apps/h5-partner/src/pages/BillsPage.tsx @@ -12,16 +12,16 @@ function fmtMoney(n: number) { function billStatusLabel(status: string) { switch (status) { - case 'DRAFT': return '待确认'; - case 'CONFIRMED': return '审核中'; - case 'PAID': return '已结算'; + case 'AWAITING_CONFIRM': return '待确认'; + case 'UNPAID': return '未打款'; + case 'PAID': return '已打款'; case 'REJECTED': return '已驳回'; default: return status; } } -function canApplyPayment(status: string) { - return status === 'DRAFT' || status === 'REJECTED'; +function canConfirm(status: string) { + return status === 'AWAITING_CONFIRM'; } export default function BillsPage() { @@ -31,6 +31,7 @@ export default function BillsPage() { const [confirmed, setConfirmed] = useState(false); const [submitting, setSubmitting] = useState(false); const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState([]); async function loadBills() { setLoading(true); @@ -51,7 +52,7 @@ export default function BillsPage() { const billId = searchParams.get('id'); const actionable = useMemo( - () => bills.filter((b) => canApplyPayment(b.status)), + () => bills.filter((b) => canConfirm(b.status)), [bills], ); const bill = billId @@ -59,20 +60,32 @@ export default function BillsPage() { : actionable[0] ?? bills[0]; const status = String(bill?.status || ''); - const showApplyForm = !!bill && canApplyPayment(status); - const isReviewing = status === 'CONFIRMED'; + const showApplyForm = !!bill && canConfirm(status); + const isUnpaid = status === 'UNPAID'; const isRejected = status === 'REJECTED'; const isPaid = status === 'PAID'; - async function confirmBill() { - if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return; + function askConfirm(ids: string[]) { + if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) { + return; + } + void doConfirm(ids); + } + + async function doConfirm(ids: string[]) { setSubmitting(true); try { - await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, { - method: 'POST', - }); - toastSuccess('申请已提交,请耐心等待总部打款审核'); + if (ids.length === 1) { + await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' }); + } else { + await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + toastSuccess('已确认,等待总部打款'); setConfirmed(false); + setSelectedIds([]); await loadBills(); } catch (e) { toastError(e instanceof Error ? e.message : '提交失败'); @@ -81,6 +94,10 @@ export default function BillsPage() { } } + function toggleSelect(id: string) { + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + } + return ( navigate('/center/settlement')} /> @@ -90,7 +107,7 @@ export default function BillsPage() {
@@ -100,7 +117,7 @@ export default function BillsPage() { 数据核算
-
+
{showApplyForm && !confirmed ? '2' : ( check )} @@ -108,20 +125,20 @@ export default function BillsPage() { 账单确认
-
+
{isPaid ? ( check ) : '3'}
- - {isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'} + + {isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
{loading &&
加载中…
} - {!loading && !bill &&
暂无账单
} + {!loading && !bill &&
暂无待确认账单
} {bill && (
@@ -132,7 +149,7 @@ export default function BillsPage() {

SETTLEMENT PERIOD

{String(bill.billNo || '月度结算账单')}

- + {billStatusLabel(status)}
@@ -141,17 +158,17 @@ export default function BillsPage() {
error
-

打款申请已驳回

+

账单已驳回

{bill.rejectReason}

)} - {isReviewing && ( + {isUnpaid && (
hourglass_top

- 您已提交打款申请,总部审核中,请耐心等待。 + 您已确认账单,总部打款中,请耐心等待。

)} @@ -178,29 +195,60 @@ export default function BillsPage() { )} - {bills.length > 1 && ( + {bills.length > 0 && (
-

全部账单

- {bills.map((b) => ( - + )} +
+ {bills.map((b) => { + const id = String(b.id); + const selectable = canConfirm(b.status); + return ( +
+ {selectable ? ( + toggleSelect(id)} + style={{ marginTop: 4 }} + /> + ) : ( + + )} +
- - ))} + ); + })} )} @@ -209,9 +257,7 @@ export default function BillsPage() {
info

- {isRejected - ? '请根据驳回理由核对后重新确认并申请打款。' - : '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'} + 确认后账单变为「未打款」,由总部完成打款。如有异议请先联系城市运营经理。

@@ -225,9 +271,9 @@ export default function BillsPage() { className="partner-btn-primary" disabled={!confirmed || submitting} style={{ opacity: confirmed ? 1 : 0.5 }} - onClick={() => void confirmBill()} + onClick={() => askConfirm([String(bill!.id)])} > - {submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'} + {submitting ? '正在提交...' : '确认账单'} {!submitting && payments} diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index b7c06a5..26b49a0 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -57,7 +57,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag }, [loadCenter]); const finance = useMemo(() => { - const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED'); + const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM'); const settled = bills.filter((b) => b.status === 'PAID'); const paidCount = settled.length; const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length; @@ -65,7 +65,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag pendingBillCount: pending.length, rejectedCount, pendingTotal: bills - .filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED') + .filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED') .reduce((s, b) => s + Number(b.totalAmount || 0), 0), settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), diff --git a/apps/h5-partner/src/pages/SettlementPage.tsx b/apps/h5-partner/src/pages/SettlementPage.tsx index 0f3f5af..60daead 100644 --- a/apps/h5-partner/src/pages/SettlementPage.tsx +++ b/apps/h5-partner/src/pages/SettlementPage.tsx @@ -13,9 +13,9 @@ function fmtMoney(n: number) { function billStatusLabel(status: string) { switch (status) { - case 'DRAFT': return '待结算'; - case 'CONFIRMED': return '审核中'; - case 'PAID': return '已结算'; + case 'AWAITING_CONFIRM': return '待确认'; + case 'UNPAID': return '未打款'; + case 'PAID': return '已打款'; case 'REJECTED': return '已驳回'; default: return status; } @@ -23,8 +23,8 @@ function billStatusLabel(status: string) { function billStatusClass(status: string) { switch (status) { - case 'DRAFT': return 'partner-settlement-status--pending'; - case 'CONFIRMED': return 'partner-settlement-status--reviewing'; + case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending'; + case 'UNPAID': return 'partner-settlement-status--reviewing'; case 'PAID': return 'partner-settlement-status--settled'; case 'REJECTED': return 'partner-settlement-status--rejected'; default: return ''; @@ -58,7 +58,7 @@ export default function SettlementPage() { }, [navigate, loadBills]); const summary = useMemo(() => { - const pending = bills.filter((b) => b.status === 'DRAFT'); + const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM'); const settled = bills.filter((b) => b.status === 'PAID'); const currentMonth = bills.filter((b) => { const d = new Date(b.periodStart); @@ -77,9 +77,9 @@ export default function SettlementPage() { const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; if (!matchMonth) return false; if (statusFilter === 'all') return true; - if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED'; + if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM'; if (statusFilter === 'settled') return b.status === 'PAID'; - if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; + if (statusFilter === 'reviewing') return b.status === 'UNPAID'; if (statusFilter === 'rejected') return b.status === 'REJECTED'; return true; }); @@ -100,10 +100,10 @@ export default function SettlementPage() { const statusTabs: Array<{ key: StatusFilter; label: string }> = [ { key: 'all', label: '全部' }, - { key: 'pending', label: '待结算' }, - { key: 'reviewing', label: '审核中' }, + { key: 'pending', label: '待确认' }, + { key: 'reviewing', label: '未打款' }, { key: 'rejected', label: '已驳回' }, - { key: 'settled', label: '已结算' }, + { key: 'settled', label: '已打款' }, ]; return ( @@ -202,7 +202,7 @@ export default function SettlementPage() { 驳回:{bill.rejectReason}

) : null} -

+

¥{fmtMoney(Number(bill.totalAmount || 0))}

diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index ae9af3f..871679f 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -1,3 +1,5 @@ +export type FinancePayStatus = 'UNPAID' | 'PAID'; + export interface StorePayoutDto { id: string; redeemAmount: number; @@ -5,9 +7,28 @@ export interface StorePayoutDto { status: 'PENDING' | 'PAID'; expectedPayAt: string; paidAt?: string | null; + storeBillId?: string | null; } -export type PartnerBillStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'REJECTED'; +export type PartnerBillStatus = + | 'PENDING_REVIEW' + | 'AWAITING_CONFIRM' + | 'UNPAID' + | 'PAID' + | 'REJECTED'; + +export const PARTNER_BILL_STATUS_LABELS: Record = { + PENDING_REVIEW: '待审核', + AWAITING_CONFIRM: '待合伙人确认', + UNPAID: '未打款', + PAID: '已打款', + REJECTED: '已驳回', +}; + +export const FINANCE_PAY_STATUS_LABELS: Record = { + UNPAID: '未打款', + PAID: '已打款', +}; export interface PartnerBillDto { id: string; @@ -19,6 +40,7 @@ export interface PartnerBillDto { periodStart: string; periodEnd: string; confirmedAt?: string | null; + sentAt?: string | null; paidAt?: string | null; rejectReason?: string | null; } @@ -27,6 +49,41 @@ export interface PartnerBillDetailDto extends PartnerBillDto { partnerId: string; } +export interface StoreBillDto { + id: string; + billNo: string; + storeId: string; + billDate: string; + redeemCount: number; + redeemAmount: number; + settlementRate: number; + payoutAmount: number; + status: FinancePayStatus; + paidAt?: string | null; +} + +export interface WineryBillDto { + id: string; + billNo: string; + billDate: string; + orderCount: number; + orderAmount: number; + wineryRate: number; + wineryAmount: number; + status: FinancePayStatus; + paidAt?: string | null; +} + +export interface WineryBillItemDto { + id: string; + orderId: string; + orderNo: string; + deliveryType: string; + payAmount: number; + wineryAmount: number; + paidAt: string; +} + /** 门店结算默认比例(核销金额 × 比例) */ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6; @@ -69,4 +126,3 @@ export type WineryOrderBillRow = { paidAt: string; receiverCity?: string; }; - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11098d7..3f6db90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,19 +194,19 @@ importers: version: link:../../packages/weixin-sdk '@tarojs/components': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) + version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) '@tarojs/helper': specifier: 4.2.0 version: 4.2.0 '@tarojs/plugin-framework-react': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(postcss@8.5.15)) + version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) '@tarojs/plugin-html': specifier: 4.2.0 version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0) '@tarojs/plugin-platform-h5': specifier: 4.2.0 - version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(postcss@8.5.15)) + version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) '@tarojs/plugin-platform-weapp': specifier: 4.2.0 version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0) @@ -215,7 +215,7 @@ importers: version: 4.2.0(react@18.3.1) '@tarojs/router': specifier: 4.2.0 - version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15))) + version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))) '@tarojs/runtime': specifier: 4.2.0 version: 4.2.0 @@ -224,7 +224,7 @@ importers: version: 4.2.0 '@tarojs/taro': specifier: 4.2.0 - version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) + version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -276,19 +276,19 @@ importers: version: link:../../packages/weixin-sdk '@tarojs/components': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) '@tarojs/helper': specifier: 4.2.0 version: 4.2.0 '@tarojs/plugin-framework-react': specifier: 4.2.0 - version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(postcss@8.5.15)) '@tarojs/plugin-html': specifier: 4.2.0 version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0) '@tarojs/plugin-platform-h5': specifier: 4.2.0 - version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(postcss@8.5.15)) '@tarojs/plugin-platform-weapp': specifier: 4.2.0 version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0) @@ -297,7 +297,7 @@ importers: version: 4.2.0(react@18.3.1) '@tarojs/router': specifier: 4.2.0 - version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))) + version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15))) '@tarojs/runtime': specifier: 4.2.0 version: 4.2.0 @@ -306,7 +306,7 @@ importers: version: 4.2.0 '@tarojs/taro': specifier: 4.2.0 - version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) + version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) element-china-area-data: specifier: ^6.1.0 version: 6.1.0 @@ -423,6 +423,9 @@ importers: '@nestjs/platform-express': specifier: ^10.4.0 version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@nestjs/schedule': + specifier: ^6.1.3 + version: 6.1.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@prisma/client': specifier: ^5.18.0 version: 5.22.0(prisma@5.22.0) @@ -1634,6 +1637,12 @@ packages: '@nestjs/common': ^10.0.0 '@nestjs/core': ^10.0.0 + '@nestjs/schedule@6.1.3': + resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@10.2.3': resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} peerDependencies: @@ -2564,6 +2573,9 @@ packages: '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/luxon@3.7.2': + resolution: {integrity: sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -3360,6 +3372,10 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -7980,6 +7996,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.1.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -9037,6 +9059,8 @@ snapshots: '@types/lodash@4.17.24': {} + '@types/luxon@3.7.2': {} + '@types/mime@1.3.5': {} '@types/minimatch@6.0.0': @@ -10082,6 +10106,11 @@ snapshots: dependencies: luxon: 3.7.2 + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.2 + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index a074540..fc9f566 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -28,6 +28,7 @@ "@nestjs/core": "^10.4.0", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.4.0", + "@nestjs/schedule": "^6.1.3", "@prisma/client": "^5.18.0", "ali-oss": "^6.23.0", "bullmq": "^5.12.0", diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 521a622..5a1fa6c 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -199,12 +199,19 @@ enum HqAdminRole { } enum PartnerBillStatus { - DRAFT - CONFIRMED + PENDING_REVIEW + AWAITING_CONFIRM + UNPAID PAID REJECTED } +// 酒厂/门店对账单打款状态 +enum FinancePayStatus { + UNPAID + PAID +} + enum StoreStatus { OPEN PAUSED @@ -646,8 +653,9 @@ model PartnerBill { orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2) redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2) totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) - status PartnerBillStatus @default(DRAFT) + status PartnerBillStatus @default(PENDING_REVIEW) confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3) + sentAt DateTime? @map("sent_at") @db.DateTime(3) paidAt DateTime? @map("paid_at") @db.DateTime(3) rejectReason String? @map("reject_reason") @db.VarChar(500) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) @@ -655,6 +663,7 @@ model PartnerBill { partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict) @@index([partnerAccountId, status]) + @@unique([partnerAccountId, periodStart]) @@map("partner_bill") } @@ -830,6 +839,7 @@ model Store { redeemPendingRecords RedeemPendingRecord[] ratings StoreRating[] payouts StorePayout[] + storeBills StoreBill[] @@index([cityId, status]) @@index([partnerAccountId]) @@ -1104,10 +1114,32 @@ model StoreRating { @@map("user_store_rating") } +model StoreBill { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + billNo String @unique @map("bill_no") @db.VarChar(32) + storeId BigInt @map("store_id") @db.UnsignedBigInt + billDate DateTime @map("bill_date") @db.DateTime(3) + redeemCount Int @default(0) @map("redeem_count") + redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2) + settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4) + payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2) + status FinancePayStatus @default(UNPAID) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + payouts StorePayout[] + + @@unique([storeId, billDate]) + @@index([status, billDate]) + @@map("store_bill") +} + model StorePayout { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt storeId BigInt @map("store_id") @db.UnsignedBigInt + storeBillId BigInt? @map("store_bill_id") @db.UnsignedBigInt redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2) payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2) settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4) @@ -1119,11 +1151,49 @@ model StorePayout { redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict) store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + storeBill StoreBill? @relation(fields: [storeBillId], references: [id], onDelete: SetNull) @@index([storeId, status]) + @@index([storeBillId]) @@map("store_payout") } +model WineryBill { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + billNo String @unique @map("bill_no") @db.VarChar(32) + billDate DateTime @map("bill_date") @db.DateTime(3) + orderCount Int @default(0) @map("order_count") + orderAmount Decimal @map("order_amount") @db.Decimal(10, 2) + wineryRate Decimal @map("winery_rate") @db.Decimal(5, 4) + wineryAmount Decimal @map("winery_amount") @db.Decimal(10, 2) + status FinancePayStatus @default(UNPAID) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + items WineryBillItem[] + + @@unique([billDate]) + @@index([status, billDate]) + @@map("winery_bill") +} + +model WineryBillItem { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + wineryBillId BigInt @map("winery_bill_id") @db.UnsignedBigInt + orderId BigInt @unique @map("order_id") @db.UnsignedBigInt + orderNo String @map("order_no") @db.VarChar(32) + deliveryType String @map("delivery_type") @db.VarChar(16) + payAmount Decimal @map("pay_amount") @db.Decimal(10, 2) + wineryAmount Decimal @map("winery_amount") @db.Decimal(10, 2) + paidAt DateTime @map("paid_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + wineryBill WineryBill @relation(fields: [wineryBillId], references: [id], onDelete: Cascade) + + @@index([wineryBillId]) + @@map("winery_bill_item") +} + // ─── LOG ────────────────────────────────────────────── model LogThirdParty { diff --git a/server/dukang-api/prisma/seed-prev1.ts b/server/dukang-api/prisma/seed-prev1.ts index b6742a2..70d71ce 100644 --- a/server/dukang-api/prisma/seed-prev1.ts +++ b/server/dukang-api/prisma/seed-prev1.ts @@ -218,7 +218,7 @@ async function main() { orderCommission: 1200, redeemCommission: 800, totalAmount: 2000, - status: 'CONFIRMED', + status: 'UNPAID', confirmedAt: now, }, }); diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index 0010a31..953fbc5 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -896,7 +896,7 @@ async function main() { totalAmount: 2000, - status: 'CONFIRMED', + status: 'UNPAID', confirmedAt: now, diff --git a/server/dukang-api/prisma/sql/remap-partner-bill-status.sql b/server/dukang-api/prisma/sql/remap-partner-bill-status.sql new file mode 100644 index 0000000..6978006 --- /dev/null +++ b/server/dukang-api/prisma/sql/remap-partner-bill-status.sql @@ -0,0 +1,7 @@ +-- Optional one-time data remap before/after prisma db push when upgrading PartnerBillStatus. +-- Run against MySQL if legacy DRAFT/CONFIRMED rows exist. + +-- Step A (before removing old enum values): widen enum if needed via Prisma push, +-- then remap: +UPDATE partner_bill SET status = 'PENDING_REVIEW' WHERE status = 'DRAFT'; +UPDATE partner_bill SET status = 'UNPAID' WHERE status = 'CONFIRMED'; diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 079ffaa..1a8603e 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -47,10 +47,17 @@ export const HqOperationAction = { TICKET_REJECT: 'TICKET_REJECT', STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM', STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM', + STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM', + STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM', PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE', + PARTNER_BILL_SEND: 'PARTNER_BILL_SEND', + PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND', PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM', PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID', + PARTNER_BILL_BATCH_MARK_PAID: 'PARTNER_BILL_BATCH_MARK_PAID', PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT', + WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM', + WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM', REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN', REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM', PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', @@ -114,10 +121,17 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.TICKET_REJECT]: '工单驳回', [HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认', [HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款', + [HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款', + [HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款', [HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单', + [HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单', + [HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单', [HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单', [HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算', + [HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID]: '批量合伙人账单结算', [HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请', + [HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款', + [HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', diff --git a/server/dukang-api/src/jobs/jobs.module.ts b/server/dukang-api/src/jobs/jobs.module.ts index d30cd2b..ebd2aa7 100644 --- a/server/dukang-api/src/jobs/jobs.module.ts +++ b/server/dukang-api/src/jobs/jobs.module.ts @@ -1,11 +1,19 @@ import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; import { BullModule } from '@nestjs/bullmq'; import { TradeModule } from '../modules/trade/trade.module'; +import { SettlementModule } from '../modules/settlement/settlement.module'; import { DeliveryProcessor } from './delivery.processor'; +import { SettlementScheduler } from './settlement.scheduler'; import { DELIVERY_QUEUE } from './jobs.constants'; @Module({ - imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), TradeModule], - providers: [DeliveryProcessor], + imports: [ + ScheduleModule.forRoot(), + BullModule.registerQueue({ name: DELIVERY_QUEUE }), + TradeModule, + SettlementModule, + ], + providers: [DeliveryProcessor, SettlementScheduler], }) export class JobsModule {} diff --git a/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts new file mode 100644 index 0000000..7c6bda9 --- /dev/null +++ b/server/dukang-api/src/jobs/settlement.scheduler.ts @@ -0,0 +1,45 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { SettlementService } from '../modules/settlement/settlement.service'; + +/** + * 财务对账单定时任务(Asia/Shanghai) + * - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00) + * - 每月 1 日 08:00:合伙人上一自然月账单 + */ +@Injectable() +export class SettlementScheduler { + private readonly logger = new Logger(SettlementScheduler.name); + + constructor(private readonly settlementService: SettlementService) {} + + @Cron('0 8 * * *', { timeZone: 'Asia/Shanghai' }) + async handleDailyBills() { + this.logger.log('Daily settlement bills job start'); + try { + const winery = await this.settlementService.generateWineryBillForDay(); + this.logger.log(`Winery bill: ${JSON.stringify(winery)}`); + } catch (e) { + this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e); + } + try { + const store = await this.settlementService.generateStoreBillsForDay(); + this.logger.log(`Store bills: ${JSON.stringify(store)}`); + } catch (e) { + this.logger.error('Store bill job failed', e instanceof Error ? e.stack : e); + } + } + + @Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' }) + async handleMonthlyPartnerBills() { + this.logger.log('Monthly partner bills job start'); + try { + const result = await this.settlementService.generatePreviousMonthPartnerBills(); + this.logger.log( + `Partner bills: total=${result.total} success=${result.success} failed=${result.failed}`, + ); + } catch (e) { + this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e); + } + } +} diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index a1eb27a..5c7365e 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -43,8 +43,8 @@ export class AdminDashboardService { this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }), this.prisma.orderDelivery.count(), this.prisma.storePayout.count({ where: { status: 'PENDING' } }), - this.prisma.partnerBill.count({ where: { status: 'CONFIRMED' } }), - this.prisma.partnerBill.count({ where: { status: 'DRAFT' } }), + this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }), + this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }), this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), ]); diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 994772f..67917b0 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -28,6 +28,11 @@ export class SettlementController { return this.settlementService.listPartnerBills(user.actorId); } + @Post('bills/batch-confirm') + batchConfirm(@CurrentUser() user: AuthUser, @Body() body: { ids: string[] }) { + return this.settlementService.batchPartnerConfirmBills(user.actorId, body.ids ?? []); + } + @Post('bills/:id/confirm') confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) { return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id)); @@ -126,6 +131,65 @@ export class AdminStorePayoutController { } } +@Controller('admin/store-bills') +@UseGuards(HqAuthGuard) +export class AdminStoreBillController { + constructor(private readonly settlementService: SettlementService) {} + + @Get() + list(@Query() query: Record) { + return this.settlementService.listAdminStoreBills({ + page: query.page ? Number(query.page) : 1, + pageSize: query.pageSize ? Number(query.pageSize) : 20, + status: query.status, + storeId: query.storeId, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + }); + } + + @Get('export') + export(@Query() query: Record) { + return this.settlementService.exportAdminStoreBills({ + status: query.status, + storeId: query.storeId, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + }); + } + + @Post('generate') + generate() { + return this.settlementService.generateStoreBillsForDay(); + } + + @Post('batch-confirm') + @HqOperation({ + action: HqOperationAction.STORE_BILL_BATCH_CONFIRM, + refType: 'STORE_BILL', + batch: true, + includeBody: true, + }) + batchConfirm(@Body() body: { ids: string[] }) { + return this.settlementService.batchConfirmStoreBills(body.ids ?? []); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.settlementService.getAdminStoreBill(BigInt(id)); + } + + @Post(':id/confirm') + @HqOperation({ + action: HqOperationAction.STORE_BILL_CONFIRM, + refType: 'STORE_BILL', + refIdParam: 'id', + }) + confirm(@Param('id') id: string) { + return this.settlementService.confirmStoreBill(BigInt(id)); + } +} + @Controller('admin/partner-bills') @UseGuards(HqAuthGuard) export class AdminPartnerBillController { @@ -175,11 +239,43 @@ export class AdminPartnerBillController { }); } + @Post('batch-send') + @HqOperation({ + action: HqOperationAction.PARTNER_BILL_BATCH_SEND, + refType: 'PARTNER_BILL', + batch: true, + includeBody: true, + }) + batchSend(@Body() body: { ids: string[] }) { + return this.settlementService.batchSendPartnerBills(body.ids ?? []); + } + + @Post('batch-mark-paid') + @HqOperation({ + action: HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID, + refType: 'PARTNER_BILL', + batch: true, + includeBody: true, + }) + batchMarkPaid(@Body() body: { ids: string[] }) { + return this.settlementService.batchMarkPartnerBillsPaid(body.ids ?? []); + } + @Get(':id') detail(@Param('id') id: string) { return this.settlementService.getAdminPartnerBill(BigInt(id)); } + @Post(':id/send') + @HqOperation({ + action: HqOperationAction.PARTNER_BILL_SEND, + refType: 'PARTNER_BILL', + refIdParam: 'id', + }) + send(@Param('id') id: string) { + return this.settlementService.sendPartnerBill(BigInt(id)); + } + @Post(':id/confirm') @HqOperation({ action: HqOperationAction.PARTNER_BILL_CONFIRM, @@ -226,20 +322,55 @@ export class AdminWineryBillController { return this.settlementService.listAdminWineryBills({ page: query.page ? Number(query.page) : 1, pageSize: query.pageSize ? Number(query.pageSize) : 20, + status: query.status, + dateFrom: query.dateFrom, + dateTo: query.dateTo, year: query.year ? Number(query.year) : undefined, month: query.month ? Number(query.month) : undefined, - deliveryType: query.deliveryType, }); } @Get('export') export(@Query() query: Record) { return this.settlementService.exportAdminWineryBills({ + status: query.status, + dateFrom: query.dateFrom, + dateTo: query.dateTo, year: query.year ? Number(query.year) : undefined, month: query.month ? Number(query.month) : undefined, - deliveryType: query.deliveryType, }); } + + @Post('generate') + generate() { + return this.settlementService.generateWineryBillForDay(); + } + + @Post('batch-confirm') + @HqOperation({ + action: HqOperationAction.WINERY_BILL_BATCH_CONFIRM, + refType: 'WINERY_BILL', + batch: true, + includeBody: true, + }) + batchConfirm(@Body() body: { ids: string[] }) { + return this.settlementService.batchConfirmWineryBills(body.ids ?? []); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.settlementService.getAdminWineryBill(BigInt(id)); + } + + @Post(':id/confirm') + @HqOperation({ + action: HqOperationAction.WINERY_BILL_CONFIRM, + refType: 'WINERY_BILL', + refIdParam: 'id', + }) + confirm(@Param('id') id: string) { + return this.settlementService.confirmWineryBill(BigInt(id)); + } } @Controller('partner/me') @@ -280,7 +411,6 @@ export class PartnerMeController { }); } - // 门店类子账号若未配置权限,补齐开店/门店管理,便于开闭店与重传资料 if (account.isPrimary !== 1) { const perms = Array.isArray(account.permissions) ? (account.permissions as string[]) : []; const hasStorePerm = perms.includes('store:create') || perms.includes('store:manage'); diff --git a/server/dukang-api/src/modules/settlement/settlement.module.ts b/server/dukang-api/src/modules/settlement/settlement.module.ts index 73e592e..83bfb8a 100644 --- a/server/dukang-api/src/modules/settlement/settlement.module.ts +++ b/server/dukang-api/src/modules/settlement/settlement.module.ts @@ -5,6 +5,7 @@ import { CityScopeModule } from '../city-scope/city-scope.module'; import { SettlementService } from './settlement.service'; import { AdminPartnerBillController, + AdminStoreBillController, AdminStorePayoutController, AdminWineryBillController, PartnerMeController, @@ -19,6 +20,7 @@ import { PartnerMeController, ShopPayoutController, AdminStorePayoutController, + AdminStoreBillController, AdminPartnerBillController, AdminWineryBillController, ], diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index b32e329..f340bfc 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -6,8 +6,8 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator' import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; -function generateBillNo() { - return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; +function generateBillNo(prefix: string) { + return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } function csvEscape(value: string) { @@ -15,6 +15,22 @@ function csvEscape(value: string) { return value; } +/** 上海时区自然日 00:00(用本地 Date 构造;服务器需设 Asia/Shanghai 或等价) */ +function startOfDay(d: Date) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); +} + +function dayWindow(anchor = new Date()) { + const end = startOfDay(anchor); + const start = new Date(end); + start.setDate(start.getDate() - 1); + return { start, end, billDate: start }; +} + +function round2(n: number) { + return Math.round(n * 100) / 100; +} + @Injectable() export class SettlementService { constructor( @@ -23,6 +39,8 @@ export class SettlementService { private readonly partnerCityService: PartnerCityService, ) {} + // ─── Store payout (line) ───────────────────────────── + async createStorePayout( redeemRecordId: bigint, storeId: bigint, @@ -119,164 +137,12 @@ export class SettlementService { return serializeBigInt({ items, total, page, pageSize, summary }); } - /** 门店账单按日汇总(T+1:按核销日归集) */ - async listAdminStoreDailyBills(query: { - page?: number; - pageSize?: number; - status?: string; - storeId?: string; - dateFrom?: string; - dateTo?: string; - }) { - const page = query.page ?? 1; - const pageSize = query.pageSize ?? 50; - const where = this.buildStorePayoutWhere(query); - - const payouts = await this.prisma.storePayout.findMany({ - where, - include: { - store: { select: { id: true, name: true, cityName: true, phone: true } }, - redeemRecord: { select: { createdAt: true } }, - }, - orderBy: { createdAt: 'desc' }, - }); - - type DailyAgg = { - billDate: string; - storeId: string; - storeName: string; - storePhone?: string; - cityName?: string; - redeemCount: number; - redeemAmount: number; - settlementRate: number; - payoutAmount: number; - pendingCount: number; - paidCount: number; - }; - - const map = new Map(); - for (const p of payouts) { - const billDate = (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10); - const storeId = p.storeId.toString(); - const key = `${billDate}:${storeId}`; - const existing = map.get(key); - if (!existing) { - map.set(key, { - billDate, - storeId, - storeName: p.store?.name ?? '—', - storePhone: p.store?.phone ?? undefined, - cityName: p.store?.cityName ?? undefined, - redeemCount: 1, - redeemAmount: Number(p.redeemAmount), - settlementRate: Number(p.settlementRate), - payoutAmount: Number(p.payoutAmount), - pendingCount: p.status === 'PENDING' ? 1 : 0, - paidCount: p.status === 'PAID' ? 1 : 0, - }); - } else { - existing.redeemCount += 1; - existing.redeemAmount += Number(p.redeemAmount); - existing.payoutAmount += Number(p.payoutAmount); - if (p.status === 'PENDING') existing.pendingCount += 1; - if (p.status === 'PAID') existing.paidCount += 1; - } - } - - const all = Array.from(map.values()).sort((a, b) => { - if (a.billDate === b.billDate) return a.storeName.localeCompare(b.storeName, 'zh-CN'); - return b.billDate.localeCompare(a.billDate); - }); - - const summary = { - count: all.length, - redeemAmount: Math.round(all.reduce((s, r) => s + r.redeemAmount, 0) * 100) / 100, - payoutAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100, - totalAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100, - }; - - const start = (page - 1) * pageSize; - const items = all.slice(start, start + pageSize).map((row) => ({ - ...row, - redeemAmount: Math.round(row.redeemAmount * 100) / 100, - payoutAmount: Math.round(row.payoutAmount * 100) / 100, - })); - - return { items, total: all.length, page, pageSize, summary }; - } - - async exportAdminStorePayouts(query: { - status?: string; - storeId?: string; - dateFrom?: string; - dateTo?: string; - }) { - const where = this.buildStorePayoutWhere(query); - const payouts = await this.prisma.storePayout.findMany({ - where, - orderBy: { createdAt: 'desc' }, - include: { - store: { select: { name: true, phone: true, cityName: true } }, - redeemRecord: { select: { redeemNo: true, createdAt: true } }, - }, - }); - - const header = [ - '核销日期', - '门店', - '登录手机', - '城市', - '核销单号', - '核销面额', - '结算比例', - '应付金额', - '状态', - '预计打款', - '实际打款', - ].join(','); - const rows = payouts.map((p) => - [ - (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10), - csvEscape(p.store?.name ?? ''), - csvEscape(p.store?.phone ?? ''), - csvEscape(p.store?.cityName ?? ''), - csvEscape(p.redeemRecord?.redeemNo ?? ''), - Number(p.redeemAmount), - `${Math.round(Number(p.settlementRate) * 100)}%`, - Number(p.payoutAmount), - p.status, - p.expectedPayAt.toISOString().slice(0, 10), - p.paidAt ? p.paidAt.toISOString().slice(0, 10) : '', - ].join(','), - ); - return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: payouts.length }; - } - - private buildStorePayoutWhere(query: { - status?: string; - storeId?: string; - dateFrom?: string; - dateTo?: string; - }): Prisma.StorePayoutWhereInput { - const where: Prisma.StorePayoutWhereInput = {}; - if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals']; - if (query.storeId) where.storeId = BigInt(query.storeId); - if (query.dateFrom || query.dateTo) { - const createdAt: Prisma.DateTimeFilter = {}; - if (query.dateFrom) createdAt.gte = new Date(`${query.dateFrom}T00:00:00.000`); - if (query.dateTo) createdAt.lte = new Date(`${query.dateTo}T23:59:59.999`); - where.createdAt = createdAt; - } - return where; - } - async getAdminStorePayout(id: bigint) { const payout = await this.prisma.storePayout.findUnique({ where: { id }, include: { store: true, - redeemRecord: { include: { user: { select: { userNo: true, phone: true } } } }, + redeemRecord: true, }, }); if (!payout) throw new NotFoundException('打款记录不存在'); @@ -286,7 +152,7 @@ export class SettlementService { async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) { const payout = await this.prisma.storePayout.findUnique({ where: { id } }); if (!payout) throw new NotFoundException('打款记录不存在'); - if (payout.status === 'PAID') throw new BadRequestException('已打款'); + if (payout.status !== 'PENDING') throw new BadRequestException('仅未打款记录可确认'); const updated = await this.prisma.storePayout.update({ where: { id }, @@ -297,6 +163,18 @@ export class SettlementService { }, }); + if (payout.storeBillId) { + const pending = await this.prisma.storePayout.count({ + where: { storeBillId: payout.storeBillId, status: 'PENDING' }, + }); + if (pending === 0) { + await this.prisma.storeBill.update({ + where: { id: payout.storeBillId }, + data: { status: 'PAID', paidAt: new Date() }, + }); + } + } + this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', { storeId: payout.storeId, eventName: 'store_payout_paid', @@ -325,9 +203,306 @@ export class SettlementService { return results; } - async listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + async exportAdminStorePayouts(query: { + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }) { + const where = this.buildStorePayoutWhere(query); + const items = await this.prisma.storePayout.findMany({ + where, + include: { + store: { select: { name: true, phone: true, cityName: true } }, + redeemRecord: { select: { redeemNo: true, createdAt: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + const header = ['核销单号', '门店', '城市', '核销金额', '结算比例', '应付金额', '状态', '核销时间', '打款时间'].join(','); + const rows = items.map((p) => + [ + csvEscape(p.redeemRecord.redeemNo ?? ''), + csvEscape(p.store.name), + csvEscape(p.store.cityName ?? ''), + Number(p.redeemAmount), + Number(p.settlementRate), + Number(p.payoutAmount), + p.status, + p.redeemRecord.createdAt.toISOString().slice(0, 19).replace('T', ' '), + p.paidAt ? p.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', + ].join(','), + ); + return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: items.length }; + } + + async scanDueStorePayouts() { + const now = new Date(); + const due = await this.prisma.storePayout.findMany({ + where: { status: 'PENDING', expectedPayAt: { lte: now } }, + take: 100, + }); + return serializeBigInt({ dueCount: due.length, items: due }); + } + + private buildStorePayoutWhere(query: { + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }): Prisma.StorePayoutWhereInput { + const where: Prisma.StorePayoutWhereInput = {}; + if (query.status === 'PENDING' || query.status === 'PAID') where.status = query.status; + if (query.storeId) where.storeId = BigInt(query.storeId); + if (query.dateFrom || query.dateTo) { + where.createdAt = {}; + if (query.dateFrom) where.createdAt.gte = new Date(query.dateFrom); + if (query.dateTo) { + const end = new Date(query.dateTo); + end.setHours(23, 59, 59, 999); + where.createdAt.lte = end; + } + } + return where; + } + + // ─── Store bills (daily header) ────────────────────── + + /** 生成指定自然日窗口内的门店对账单(默认昨日) */ + async generateStoreBillsForDay(anchor = new Date()) { + const { start, end, billDate } = dayWindow(anchor); + const payouts = await this.prisma.storePayout.findMany({ + where: { + storeBillId: null, + createdAt: { gte: start, lt: end }, + }, + include: { store: { select: { id: true, settlementRate: true } } }, + }); + + const byStore = new Map(); + for (const p of payouts) { + const key = p.storeId.toString(); + const list = byStore.get(key) ?? []; + list.push(p); + byStore.set(key, list); + } + + let created = 0; + let skipped = 0; + for (const [storeIdStr, list] of byStore) { + const storeId = BigInt(storeIdStr); + const existing = await this.prisma.storeBill.findUnique({ + where: { storeId_billDate: { storeId, billDate } }, + }); + if (existing) { + if (existing.status === 'PAID') { + skipped += 1; + continue; + } + const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0)); + const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0)); + const rate = Number(list[0].settlementRate); + await this.prisma.$transaction(async (tx) => { + await tx.storeBill.update({ + where: { id: existing.id }, + data: { + redeemCount: existing.redeemCount + list.length, + redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount), + payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount), + settlementRate: rate, + }, + }); + await tx.storePayout.updateMany({ + where: { id: { in: list.map((p) => p.id) } }, + data: { storeBillId: existing.id }, + }); + }); + created += 1; + continue; + } + + const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0)); + const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0)); + const rate = Number(list[0].settlementRate); + await this.prisma.$transaction(async (tx) => { + const bill = await tx.storeBill.create({ + data: { + billNo: generateBillNo('SB'), + storeId, + billDate, + redeemCount: list.length, + redeemAmount, + settlementRate: rate, + payoutAmount, + status: 'UNPAID', + }, + }); + await tx.storePayout.updateMany({ + where: { id: { in: list.map((p) => p.id) } }, + data: { storeBillId: bill.id }, + }); + }); + created += 1; + } + + return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length }; + } + + async listAdminStoreBills(query: { + page?: number; + pageSize?: number; + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where = this.buildStoreBillWhere(query); + + const [items, total, aggregates] = await Promise.all([ + this.prisma.storeBill.findMany({ + where, + orderBy: [{ billDate: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + include: { + store: { select: { id: true, name: true, cityName: true, phone: true } }, + }, + }), + this.prisma.storeBill.count({ where }), + this.prisma.storeBill.aggregate({ + where, + _sum: { redeemAmount: true, payoutAmount: true }, + _count: true, + }), + ]); + + const summary = { + count: aggregates._count, + redeemAmount: Number(aggregates._sum.redeemAmount ?? 0), + payoutAmount: Number(aggregates._sum.payoutAmount ?? 0), + totalAmount: Number(aggregates._sum.payoutAmount ?? 0), + }; + + return serializeBigInt({ items, total, page, pageSize, summary }); + } + + async getAdminStoreBill(id: bigint) { + const bill = await this.prisma.storeBill.findUnique({ + where: { id }, + include: { + store: { select: { id: true, name: true, cityName: true, phone: true } }, + payouts: { + include: { redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } } }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + if (!bill) throw new NotFoundException('门店对账单不存在'); + return serializeBigInt(bill); + } + + async confirmStoreBill(id: bigint) { + const bill = await this.prisma.storeBill.findUnique({ where: { id } }); + if (!bill) throw new NotFoundException('门店对账单不存在'); + if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款'); + + const paidAt = new Date(); + const updated = await this.prisma.$transaction(async (tx) => { + const b = await tx.storeBill.update({ + where: { id }, + data: { status: 'PAID', paidAt }, + }); + await tx.storePayout.updateMany({ + where: { storeBillId: id, status: 'PENDING' }, + data: { status: 'PAID', paidAt }, + }); + return b; + }); + return serializeBigInt(updated); + } + + async batchConfirmStoreBills(ids: string[]) { + const results: Array<{ id: string; ok: boolean; message?: string }> = []; + for (const id of ids) { + try { + await this.confirmStoreBill(BigInt(id)); + results.push({ id, ok: true }); + } catch (e) { + results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); + } + } + return results; + } + + async exportAdminStoreBills(query: { + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }) { + const where = this.buildStoreBillWhere(query); + const bills = await this.prisma.storeBill.findMany({ + where, + include: { store: { select: { name: true, phone: true, cityName: true } } }, + orderBy: { billDate: 'desc' }, + }); + const header = ['账单号', '账单日', '门店', '城市', '核销笔数', '核销金额', '结算比例', '应付金额', '状态', '打款时间'].join(','); + const rows = bills.map((b) => + [ + csvEscape(b.billNo), + b.billDate.toISOString().slice(0, 10), + csvEscape(b.store.name), + csvEscape(b.store.cityName ?? ''), + b.redeemCount, + Number(b.redeemAmount), + Number(b.settlementRate), + Number(b.payoutAmount), + b.status, + b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', + ].join(','), + ); + return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; + } + + private buildStoreBillWhere(query: { + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }): Prisma.StoreBillWhereInput { + const where: Prisma.StoreBillWhereInput = {}; + if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status; + if (query.storeId) where.storeId = BigInt(query.storeId); + if (query.dateFrom || query.dateTo) { + where.billDate = {}; + if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom)); + if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo)); + } + return where; + } + + /** @deprecated 兼容旧 daily 聚合视图 */ + async listAdminStoreDailyBills(query: { + page?: number; + pageSize?: number; + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }) { + return this.listAdminStoreBills(query); + } + + // ─── Partner bills ─────────────────────────────────── + + async listPartnerBills(partnerAccountId: bigint) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const bills = await this.prisma.partnerBill.findMany({ - where: { partnerAccountId: primary.id }, + where: { + partnerAccountId: primary.id, + status: { in: ['AWAITING_CONFIRM', 'UNPAID', 'PAID', 'REJECTED'] }, + }, orderBy: { createdAt: 'desc' }, }); this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { @@ -402,7 +577,6 @@ export class SettlementService { return where; } - /** 为所有主合伙人生成指定自然月账单 */ async generateAllPartnerBills(body: { year: number; month: number }) { const partners = await this.prisma.partnerAccount.findMany({ where: { isPrimary: 1, status: 'ACTIVE' }, @@ -434,6 +608,12 @@ export class SettlementService { }; } + /** 每月 1 日任务:生成上一自然月账单 */ + async generatePreviousMonthPartnerBills(anchor = new Date()) { + const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1); + return this.generateAllPartnerBills({ year: prev.getFullYear(), month: prev.getMonth() + 1 }); + } + async getAdminPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id }, @@ -449,15 +629,13 @@ export class SettlementService { const periodStart = new Date(body.year, body.month - 1, 1); const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999); - const existing = await this.prisma.partnerBill.findFirst({ + const existing = await this.prisma.partnerBill.findUnique({ where: { - partnerAccountId: primary.id, - periodStart, - status: { not: 'DRAFT' }, + partnerAccountId_periodStart: { partnerAccountId: primary.id, periodStart }, }, }); - if (existing && existing.status !== 'DRAFT') { - throw new BadRequestException('该月账单已确认,不可重复生成'); + if (existing && existing.status !== 'PENDING_REVIEW') { + throw new BadRequestException('该月账单已进入审核流程,不可重复生成'); } if (!primary.cityId) { @@ -488,78 +666,113 @@ export class SettlementService { select: { id: true }, }); const storeIds = stores.map((s) => s.id); - const redeems = await this.prisma.redeemRecord.findMany({ - where: { - storeId: { in: storeIds }, - createdAt: { gte: periodStart, lte: periodEnd }, - }, - }); + const redeems = storeIds.length + ? await this.prisma.redeemRecord.findMany({ + where: { + storeId: { in: storeIds }, + createdAt: { gte: periodStart, lte: periodEnd }, + }, + }) + : []; const redeemCommission = redeems.reduce( (sum, r) => sum + Number(r.amount) * redeemCommissionRate, 0, ); - const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100; + const totalAmount = round2(orderCommission + redeemCommission); - const draft = await this.prisma.partnerBill.findFirst({ - where: { partnerAccountId: primary.id, periodStart, status: 'DRAFT' }, - }); - - const bill = draft + const bill = existing ? await this.prisma.partnerBill.update({ - where: { id: draft.id }, - data: { orderCommission, redeemCommission, totalAmount, periodEnd }, + where: { id: existing.id }, + data: { + orderCommission: round2(orderCommission), + redeemCommission: round2(redeemCommission), + totalAmount, + periodEnd, + status: 'PENDING_REVIEW', + }, }) : await this.prisma.partnerBill.create({ data: { - billNo: generateBillNo(), + billNo: generateBillNo('PB'), partnerAccountId: primary.id, periodStart, periodEnd, - orderCommission, - redeemCommission, + orderCommission: round2(orderCommission), + redeemCommission: round2(redeemCommission), totalAmount, - status: 'DRAFT', + status: 'PENDING_REVIEW', }, }); return serializeBigInt(bill); } - async confirmPartnerBill(id: bigint) { + /** HQ:发送给合伙人(待审核 → 待合伙人确认) */ + async sendPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') { - throw new BadRequestException('仅待确认或已驳回账单可确认'); + if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'REJECTED') { + throw new BadRequestException('仅待审核或已驳回账单可发送'); } - const updated = await this.prisma.partnerBill.update({ where: { id }, data: { - status: 'CONFIRMED', - confirmedAt: new Date(), + status: 'AWAITING_CONFIRM', + sentAt: new Date(), rejectReason: null, }, }); - return serializeBigInt(updated); } - /** 合伙人端:确认账单并申请打款(DRAFT/REJECTED → CONFIRMED) */ + async batchSendPartnerBills(ids: string[]) { + const results: Array<{ id: string; ok: boolean; message?: string }> = []; + for (const id of ids) { + try { + await this.sendPartnerBill(BigInt(id)); + results.push({ id, ok: true }); + } catch (e) { + results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); + } + } + return results; + } + + /** @deprecated HQ 代确认:待审核/待确认 → 未打款 */ + async confirmPartnerBill(id: bigint) { + const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); + if (!bill) throw new NotFoundException('账单不存在'); + if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'AWAITING_CONFIRM' && bill.status !== 'REJECTED') { + throw new BadRequestException('当前状态不可确认'); + } + const updated = await this.prisma.partnerBill.update({ + where: { id }, + data: { + status: 'UNPAID', + confirmedAt: new Date(), + sentAt: bill.sentAt ?? new Date(), + rejectReason: null, + }, + }); + return serializeBigInt(updated); + } + + /** 合伙人确认:待合伙人确认 → 未打款 */ async partnerConfirmBill(partnerAccountId: bigint, billId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const bill = await this.prisma.partnerBill.findUnique({ where: { id: billId } }); if (!bill || bill.partnerAccountId !== primary.id) { throw new NotFoundException('账单不存在'); } - if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') { - throw new BadRequestException('当前账单不可再次申请打款'); + if (bill.status !== 'AWAITING_CONFIRM') { + throw new BadRequestException('当前账单不可确认'); } const updated = await this.prisma.partnerBill.update({ where: { id: billId }, data: { - status: 'CONFIRMED', + status: 'UNPAID', confirmedAt: new Date(), rejectReason: null, }, @@ -576,6 +789,19 @@ export class SettlementService { return serializeBigInt(updated); } + async batchPartnerConfirmBills(partnerAccountId: bigint, ids: string[]) { + const results: Array<{ id: string; ok: boolean; message?: string }> = []; + for (const id of ids) { + try { + await this.partnerConfirmBill(partnerAccountId, BigInt(id)); + results.push({ id, ok: true }); + } catch (e) { + results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); + } + } + return results; + } + async rejectPartnerBill(id: bigint, reason: string) { const rejectReason = reason.trim(); if (!rejectReason) throw new BadRequestException('请填写驳回理由'); @@ -583,8 +809,8 @@ export class SettlementService { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status !== 'CONFIRMED') { - throw new BadRequestException('仅待打款审核的账单可驳回'); + if (bill.status !== 'AWAITING_CONFIRM' && bill.status !== 'UNPAID') { + throw new BadRequestException('仅待合伙人确认或未打款账单可驳回'); } const updated = await this.prisma.partnerBill.update({ @@ -598,10 +824,10 @@ export class SettlementService { return serializeBigInt(updated); } - async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) { + async markPartnerBillPaid(id: bigint, _dto: { paymentRef?: string } = {}) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅待打款审核的账单可标记打款'); + if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可标记打款'); const updated = await this.prisma.partnerBill.update({ where: { id }, @@ -611,6 +837,19 @@ export class SettlementService { return serializeBigInt(updated); } + async batchMarkPartnerBillsPaid(ids: string[]) { + const results: Array<{ id: string; ok: boolean; message?: string }> = []; + for (const id of ids) { + try { + await this.markPartnerBillPaid(BigInt(id)); + results.push({ id, ok: true }); + } catch (e) { + results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); + } + } + return results; + } + async exportPartnerBills(query: { partnerId?: string; status?: string; @@ -634,6 +873,7 @@ export class SettlementService { '核销佣金', '合计应付', '状态', + '发送时间', '确认时间', '打款时间', '驳回理由', @@ -649,6 +889,7 @@ export class SettlementService { Number(b.redeemCommission), Number(b.totalAmount), b.status, + b.sentAt ? b.sentAt.toISOString().slice(0, 10) : '', b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '', b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '', csvEscape(b.rejectReason ?? ''), @@ -657,134 +898,235 @@ export class SettlementService { return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; } - /** 酒厂账单:T+30,按自然月列出已付酒单,应付 = 实付 × WINERY_SETTLEMENT_RATE */ + // ─── Winery bills ──────────────────────────────────── + + async generateWineryBillForDay(anchor = new Date()) { + const { start, end, billDate } = dayWindow(anchor); + const rate = WINERY_SETTLEMENT_RATE; + + const existing = await this.prisma.wineryBill.findUnique({ where: { billDate } }); + if (existing?.status === 'PAID') { + return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' }; + } + + const orders = await this.prisma.order.findMany({ + where: { + payStatus: 'PAID', + deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, + paidAt: { gte: start, lt: end }, + }, + orderBy: { paidAt: 'asc' }, + }); + + if (orders.length === 0 && !existing) { + return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '无订单' }; + } + + const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0)); + const wineryAmount = round2(orderAmount * rate); + + const bill = await this.prisma.$transaction(async (tx) => { + const header = existing + ? await tx.wineryBill.update({ + where: { id: existing.id }, + data: { + orderCount: orders.length, + orderAmount, + wineryRate: rate, + wineryAmount, + }, + }) + : await tx.wineryBill.create({ + data: { + billNo: generateBillNo('WB'), + billDate, + orderCount: orders.length, + orderAmount, + wineryRate: rate, + wineryAmount, + status: 'UNPAID', + }, + }); + + if (existing) { + await tx.wineryBillItem.deleteMany({ where: { wineryBillId: header.id } }); + } + + if (orders.length > 0) { + await tx.wineryBillItem.createMany({ + data: orders.map((o) => ({ + wineryBillId: header.id, + orderId: o.id, + orderNo: o.orderNo, + deliveryType: o.deliveryType, + payAmount: o.payAmount, + wineryAmount: round2(Number(o.payAmount) * rate), + paidAt: o.paidAt!, + })), + }); + } + + return header; + }); + + return serializeBigInt({ + billDate: billDate.toISOString().slice(0, 10), + skipped: false, + bill, + }); + } + async listAdminWineryBills(query: { page?: number; pageSize?: number; + status?: string; + dateFrom?: string; + dateTo?: string; year?: number; month?: number; - deliveryType?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where = this.buildWineryOrderWhere(query); + const where = this.buildWineryBillWhere(query); - const [orders, total, aggregates] = await Promise.all([ - this.prisma.order.findMany({ + const [items, total, aggregates] = await Promise.all([ + this.prisma.wineryBill.findMany({ where, - orderBy: { paidAt: 'desc' }, + orderBy: { billDate: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, - include: { city: { select: { name: true } } }, }), - this.prisma.order.count({ where }), - this.prisma.order.aggregate({ + this.prisma.wineryBill.count({ where }), + this.prisma.wineryBill.aggregate({ where, - _sum: { payAmount: true }, + _sum: { orderAmount: true, wineryAmount: true }, _count: true, }), ]); - const rate = WINERY_SETTLEMENT_RATE; - const items = orders.map((o) => { - const payAmount = Number(o.payAmount); - const wineryAmount = Math.round(payAmount * rate * 100) / 100; - return { - orderId: o.id.toString(), - orderNo: o.orderNo, - deliveryType: o.deliveryType, - cityName: o.city?.name, - payAmount, - wineryRate: rate, - wineryAmount, - paidAt: o.paidAt?.toISOString() ?? '', - receiverCity: o.receiverCity, - productName: o.productName, - quantity: o.quantity, - }; - }); - - const orderAmount = Number(aggregates._sum.payAmount ?? 0); const summary = { count: aggregates._count, - orderAmount, - wineryAmount: Math.round(orderAmount * rate * 100) / 100, - totalAmount: Math.round(orderAmount * rate * 100) / 100, + orderAmount: Number(aggregates._sum.orderAmount ?? 0), + wineryAmount: Number(aggregates._sum.wineryAmount ?? 0), + totalAmount: Number(aggregates._sum.wineryAmount ?? 0), }; - return { items, total, page, pageSize, summary }; + return serializeBigInt({ items, total, page, pageSize, summary }); + } + + async getAdminWineryBill(id: bigint) { + const bill = await this.prisma.wineryBill.findUnique({ + where: { id }, + include: { items: { orderBy: { paidAt: 'desc' } } }, + }); + if (!bill) throw new NotFoundException('酒厂对账单不存在'); + return serializeBigInt(bill); + } + + async confirmWineryBill(id: bigint) { + const bill = await this.prisma.wineryBill.findUnique({ where: { id } }); + if (!bill) throw new NotFoundException('酒厂对账单不存在'); + if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款'); + const updated = await this.prisma.wineryBill.update({ + where: { id }, + data: { status: 'PAID', paidAt: new Date() }, + }); + return serializeBigInt(updated); + } + + async batchConfirmWineryBills(ids: string[]) { + const results: Array<{ id: string; ok: boolean; message?: string }> = []; + for (const id of ids) { + try { + await this.confirmWineryBill(BigInt(id)); + results.push({ id, ok: true }); + } catch (e) { + results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); + } + } + return results; } async exportAdminWineryBills(query: { + status?: string; + dateFrom?: string; + dateTo?: string; year?: number; month?: number; - deliveryType?: string; }) { - const where = this.buildWineryOrderWhere(query); - const orders = await this.prisma.order.findMany({ + const where = this.buildWineryBillWhere(query); + const bills = await this.prisma.wineryBill.findMany({ where, - orderBy: { paidAt: 'desc' }, - include: { city: { select: { name: true } } }, + include: { items: true }, + orderBy: { billDate: 'desc' }, }); - const rate = WINERY_SETTLEMENT_RATE; + const header = [ + '账单号', + '账单日', '订单号', '配送类型', - '开城城市', - '收货城市', - '商品', - '数量', '实付金额', '酒厂比例', '酒厂应付', '支付时间', + '账单状态', ].join(','); - const rows = orders.map((o) => { - const payAmount = Number(o.payAmount); - return [ - csvEscape(o.orderNo), - o.deliveryType === 'LOCAL' ? '同城' : o.deliveryType === 'CROSS_CITY' ? '跨城' : o.deliveryType, - csvEscape(o.city?.name ?? ''), - csvEscape(o.receiverCity ?? ''), - csvEscape(o.productName ?? ''), - o.quantity, - payAmount, - `${Math.round(rate * 100)}%`, - Math.round(payAmount * rate * 100) / 100, - o.paidAt ? o.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', - ].join(','); - }); - return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: orders.length }; + const rows: string[] = []; + for (const b of bills) { + if (b.items.length === 0) { + rows.push( + [ + csvEscape(b.billNo), + b.billDate.toISOString().slice(0, 10), + '', + '', + Number(b.orderAmount), + Number(b.wineryRate), + Number(b.wineryAmount), + '', + b.status, + ].join(','), + ); + continue; + } + for (const item of b.items) { + rows.push( + [ + csvEscape(b.billNo), + b.billDate.toISOString().slice(0, 10), + csvEscape(item.orderNo), + item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType, + Number(item.payAmount), + Number(b.wineryRate), + Number(item.wineryAmount), + item.paidAt.toISOString().slice(0, 19).replace('T', ' '), + b.status, + ].join(','), + ); + } + } + return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length }; } - private buildWineryOrderWhere(query: { + private buildWineryBillWhere(query: { + status?: string; + dateFrom?: string; + dateTo?: string; year?: number; month?: number; - deliveryType?: string; - }): Prisma.OrderWhereInput { - const where: Prisma.OrderWhereInput = { - payStatus: 'PAID', - deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, - }; - if (query.deliveryType === 'LOCAL' || query.deliveryType === 'CROSS_CITY') { - where.deliveryType = query.deliveryType; - } + }): Prisma.WineryBillWhereInput { + const where: Prisma.WineryBillWhereInput = {}; + if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status; if (query.year && query.month) { const start = new Date(query.year, query.month - 1, 1); const end = new Date(query.year, query.month, 0, 23, 59, 59, 999); - where.paidAt = { gte: start, lte: end }; - } else { - where.paidAt = { not: null }; + where.billDate = { gte: start, lte: end }; + } else if (query.dateFrom || query.dateTo) { + where.billDate = {}; + if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom)); + if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo)); } return where; } - - async scanDueStorePayouts() { - const now = new Date(); - const due = await this.prisma.storePayout.findMany({ - where: { status: 'PENDING', expectedPayAt: { lte: now } }, - take: 100, - }); - return serializeBigInt({ dueCount: due.length, items: due }); - } }