城市合伙人H5端结算审核

This commit is contained in:
2026-07-17 07:56:43 +08:00
parent 7fa2c6fbed
commit b36f2ea63a
14 changed files with 418 additions and 82 deletions
+5
View File
@@ -79,6 +79,11 @@ export type DashboardStats = {
partnersTotal: number; partnersTotal: number;
redeemToday: number; redeemToday: number;
deliveriesTotal: number; deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
ordersByStatus: Array<{ status: string; count: number }>; ordersByStatus: Array<{ status: string; count: number }>;
}; };
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message, Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
} from 'antd'; } from 'antd';
@@ -164,6 +165,47 @@ export default function DashboardPage() {
</Card> </Card>
</Col> </Col>
</Row> </Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审核合伙人打款"
extra={<Link to="/finance/partner-bills"></Link>}
>
<Statistic
value={stats?.pendingBills ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
<Typography.Text type="secondary">
{(stats?.pendingPartnerDraftBills ?? 0) > 0
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
: ''}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待打款门店结算"
extra={<Link to="/finance/store-bills"></Link>}
>
<Statistic
value={stats?.pendingPayouts ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}> <Card title="已合并访客账号" loading={loading}>
+93 -16
View File
@@ -4,6 +4,7 @@ import {
Card, Card,
DatePicker, DatePicker,
Form, Form,
Input,
Modal, Modal,
Select, Select,
Space, Space,
@@ -29,6 +30,7 @@ type Row = {
status: string; status: string;
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string }; partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string }; partnerAccount?: { companyName?: string; phone?: string };
}; };
@@ -36,15 +38,17 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string }; type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿', DRAFT: '待合伙人确认',
CONFIRMED: '已确认', CONFIRMED: '待打款审核',
PAID: '已打款', PAID: '已打款',
REJECTED: '已驳回',
}; };
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default', DRAFT: 'default',
CONFIRMED: 'blue', CONFIRMED: 'orange',
PAID: 'green', PAID: 'green',
REJECTED: 'red',
}; };
export default function PartnerBillsPage() { export default function PartnerBillsPage() {
@@ -66,6 +70,10 @@ export default function PartnerBillsPage() {
const [genOpen, setGenOpen] = useState(false); const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm(); const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -95,7 +103,7 @@ export default function PartnerBillsPage() {
async function confirmBill(id: string) { async function confirmBill(id: string) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
message.success('已确认'); message.success('已确认,进入待打款审核');
reload(); reload();
} }
@@ -104,10 +112,33 @@ export default function PartnerBillsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
}); });
message.success('已标记打款'); message.success('已通过并标记打款');
reload(); reload();
} }
function openReject(row: Row) {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
}
async function submitReject(values: { reason: string }) {
if (!rejectTarget) return;
setRejecting(true);
try {
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }),
});
message.success('已驳回打款申请');
setRejectOpen(false);
setRejectTarget(null);
reload();
} finally {
setRejecting(false);
}
}
async function exportExcel() { async function exportExcel() {
setExporting(true); setExporting(true);
try { try {
@@ -163,24 +194,38 @@ export default function PartnerBillsPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 120,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (s, row) => (
<Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
{s === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
),
}, },
{ {
title: '操作', title: '操作',
width: 160, width: 220,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0} wrap>
{row.status === 'DRAFT' && ( {row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}> <Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</Button> </Button>
)} )}
{row.status === 'CONFIRMED' && ( {row.status === 'CONFIRMED' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}> <>
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
</>
)} )}
</Space> </Space>
), ),
@@ -194,7 +239,7 @@ export default function PartnerBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+30 × × T+30
</Typography.Text> </Typography.Text>
</Space> </Space>
@@ -242,7 +287,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
@@ -280,7 +325,7 @@ export default function PartnerBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 1280 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -320,6 +365,38 @@ export default function PartnerBillsPage() {
</Button> </Button>
</Form> </Form>
</Modal> </Modal>
<Modal
title="驳回打款申请"
open={rejectOpen}
onCancel={() => {
setRejectOpen(false);
setRejectTarget(null);
}}
footer={null}
destroyOnClose
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
<br />
H5
</Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item
name="reason"
label="驳回理由"
rules={[
{ required: true, message: '请填写驳回理由' },
{ max: 500, message: '不超过 500 字' },
]}
>
<Input.TextArea rows={4} placeholder="请说明驳回原因,便于合伙人核对后重新申请" maxLength={500} showCount />
</Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
</Button>
</Form>
</Modal>
</div> </div>
); );
} }
+148 -49
View File
@@ -1,42 +1,96 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function billStatusLabel(status: string) {
switch (status) {
case 'DRAFT': return '待确认';
case 'CONFIRMED': return '审核中';
case 'PAID': return '已结算';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function canApplyPayment(status: string) {
return status === 'DRAFT' || status === 'REJECTED';
}
export default function BillsPage() { export default function BillsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]); const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
async function loadBills() {
setLoading(true);
try {
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
setBills(Array.isArray(list) ? list : []);
} catch {
setBills([]);
} finally {
setLoading(false);
}
}
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills); void loadBills();
}, [navigate]); }, [navigate]);
const billId = searchParams.get('id'); const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canApplyPayment(b.status)),
[bills],
);
const bill = billId const bill = billId
? bills.find((b) => String(b.id) === billId) ?? bills[0] ? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: bills[0]; : actionable[0] ?? bills[0];
function confirmBill() { const status = String(bill?.status || '');
if (!confirmed) return; const showApplyForm = !!bill && canApplyPayment(status);
const isReviewing = status === 'CONFIRMED';
const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID';
async function confirmBill() {
if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
setSubmitting(true); setSubmitting(true);
setTimeout(() => { try {
await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
method: 'POST',
});
toastSuccess('申请已提交,请耐心等待总部打款审核');
setConfirmed(false);
await loadBills();
} catch (e) {
toastError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false); setSubmitting(false);
alert('申请已提交,请耐心等待打款审核。(preV1 演示)'); }
}, 1500);
} }
return ( return (
<div className="partner-bills-page"> <div className="partner-bills-page">
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} /> <PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper"> <div className="partner-bill-stepper">
<div className="partner-stepper-inner"> <div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden> <div className="partner-stepper-line" aria-hidden>
<div className="partner-stepper-line-fill" style={{ width: '50%' }} /> <div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
/>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done"> <div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
@@ -45,17 +99,28 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--active">2</div> <div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
)}
</div>
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm">3</div> <div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
<span className="partner-step-label"></span> {isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
</div>
<span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
</span>
</div> </div>
</div> </div>
</div> </div>
{!bill && <div className="empty"></div>} {loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>}
{bill && ( {bill && (
<section className="partner-bill-card"> <section className="partner-bill-card">
@@ -66,25 +131,46 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p> <p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2> <h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div> </div>
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span> <span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</div> </div>
{isRejected && bill.rejectReason && (
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}></p>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div>
</div>
)}
{isReviewing && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
)}
<div className="partner-bill-amount"> <div className="partner-bill-amount">
<p className="text-muted body-md" style={{ marginBottom: 8 }}></p> <p className="text-muted body-md" style={{ marginBottom: 8 }}></p>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span> <span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-info-row"> <div className="partner-info-row">
<span className="text-variant body-md"></span> <span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
</div> </div>
<div className="partner-info-row"> <div className="partner-info-row">
<span className="text-variant body-md"></span> <span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -93,45 +179,58 @@ export default function BillsPage() {
{bills.length > 1 && ( {bills.length > 1 && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3> <h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.slice(1).map((b) => ( {bills.map((b) => (
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}> <button
key={String(b.id)}
type="button"
className="partner-store-card"
style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${b.id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}> <div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div> <div>
<p className="body-md">{String(b.billNo)}</p> <p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{String(b.status)}</p> <p className="label-md text-muted">{billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
</div> </div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span> <span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div> </div>
</div> </button>
))} ))}
</div> </div>
)} )}
<div className="partner-info-banner" style={{ marginTop: 16 }}> {showApplyForm && (
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span> <>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}> <div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
</p> <p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</div> {isRejected
? '请根据驳回理由核对后重新确认并申请打款。'
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
</p>
</div>
{bill && ( <footer className="partner-bill-footer">
<footer className="partner-bill-footer"> <label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}> <input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} /> <span></span>
<span></span> </label>
</label> <button
<button type="button"
type="button" className="partner-btn-primary"
className="partner-btn-primary" disabled={!confirmed || submitting}
disabled={!confirmed || submitting} style={{ opacity: confirmed ? 1 : 0.5 }}
style={{ opacity: confirmed ? 1 : 0.5 }} onClick={() => void confirmBill()}
onClick={confirmBill} >
> {submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
{submitting ? '正在提交...' : '确认并申请打款'} {!submitting && <span className="material-symbols-outlined">payments</span>}
{!submitting && <span className="material-symbols-outlined">payments</span>} </button>
</button> </footer>
</footer> </>
)} )}
</div> </div>
); );
+12 -4
View File
@@ -53,12 +53,16 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
}, [isPrimary]); }, [isPrimary]);
const finance = useMemo(() => { const finance = useMemo(() => {
const pending = bills.filter((b) => b.status === 'DRAFT'); const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
const settled = bills.filter((b) => b.status === 'PAID'); const settled = bills.filter((b) => b.status === 'PAID');
const paidCount = settled.length; const paidCount = settled.length;
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
return { return {
pendingBillCount: pending.length, pendingBillCount: pending.length,
pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0), rejectedCount,
pendingTotal: bills
.filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED')
.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.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), balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
paidCount, paidCount,
@@ -180,9 +184,13 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<span className="material-symbols-outlined">receipt_long</span> <span className="material-symbols-outlined">receipt_long</span>
</div> </div>
<div> <div>
<p className="body-md text-primary" style={{ fontWeight: 600 }}></p> <p className="body-md text-primary" style={{ fontWeight: 600 }}>
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
</p>
<p className="label-md text-primary" style={{ opacity: 0.85 }}> <p className="label-md text-primary" style={{ opacity: 0.85 }}>
{finance.pendingBillCount} {finance.rejectedCount > 0
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
</p> </p>
</div> </div>
</div> </div>
+13 -4
View File
@@ -4,7 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing'; type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
function fmtMoney(n: number) { function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -15,6 +15,7 @@ function billStatusLabel(status: string) {
case 'DRAFT': return '待结算'; case 'DRAFT': return '待结算';
case 'CONFIRMED': return '审核中'; case 'CONFIRMED': return '审核中';
case 'PAID': return '已结算'; case 'PAID': return '已结算';
case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
} }
@@ -24,6 +25,7 @@ function billStatusClass(status: string) {
case 'DRAFT': return 'partner-settlement-status--pending'; case 'DRAFT': return 'partner-settlement-status--pending';
case 'CONFIRMED': return 'partner-settlement-status--reviewing'; case 'CONFIRMED': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled'; case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected';
default: return ''; default: return '';
} }
} }
@@ -70,9 +72,10 @@ export default function SettlementPage() {
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false; if (!matchMonth) return false;
if (statusFilter === 'all') return true; if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'DRAFT'; if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true; return true;
}); });
}, [bills, month, statusFilter]); }, [bills, month, statusFilter]);
@@ -93,8 +96,9 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [ const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'pending', label: '待结算' }, { key: 'pending', label: '待结算' },
{ key: 'settled', label: '已结算' },
{ key: 'reviewing', label: '审核中' }, { key: 'reviewing', label: '审核中' },
{ key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已结算' },
]; ];
return ( return (
@@ -188,7 +192,12 @@ export default function SettlementPage() {
<span className="partner-settlement-dot" /> <span className="partner-settlement-dot" />
¥{fmtMoney(Number(bill.orderCommission || 0))} ¥{fmtMoney(Number(bill.orderCommission || 0))}
</p> </p>
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' text-primary' : ''}`}> {bill.status === 'REJECTED' && bill.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 6 }}>
{bill.rejectReason}
</p>
) : null}
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))} ¥{fmtMoney(Number(bill.totalAmount || 0))}
</p> </p>
</div> </div>
+5
View File
@@ -2985,6 +2985,11 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-success-green); color: var(--color-success-green);
} }
.partner-settlement-status--rejected {
background: rgba(166, 29, 36, 0.12);
color: var(--color-heritage-red);
}
.partner-settlement-item-meta { .partner-settlement-item-meta {
display: flex; display: flex;
align-items: center; align-items: center;
+1 -1
View File
@@ -25,7 +25,7 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly s
'partner_store_audit_rejected', 'partner_store_audit_rejected',
], ],
shipping: ['partner_order_ship', 'partner_delivery_advance'], shipping: ['partner_order_ship', 'partner_delivery_advance'],
settlement: ['partner_bill_view', 'partner_bill_detail_view'], settlement: ['partner_bill_view', 'partner_bill_detail_view', 'partner_bill_confirm'],
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'], warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
}; };
+6 -3
View File
@@ -7,21 +7,24 @@ export interface StorePayoutDto {
paidAt?: string | null; paidAt?: string | null;
} }
export type PartnerBillStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'REJECTED';
export interface PartnerBillDto { export interface PartnerBillDto {
id: string; id: string;
billNo: string; billNo: string;
orderCommission: number; orderCommission: number;
redeemCommission: number; redeemCommission: number;
totalAmount: number; totalAmount: number;
status: 'DRAFT' | 'CONFIRMED' | 'PAID'; status: PartnerBillStatus;
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
confirmedAt?: string | null;
paidAt?: string | null;
rejectReason?: string | null;
} }
export interface PartnerBillDetailDto extends PartnerBillDto { export interface PartnerBillDetailDto extends PartnerBillDto {
partnerId: string; partnerId: string;
confirmedAt?: string | null;
paidAt?: string | null;
} }
/** 门店结算默认比例(核销金额 × 比例) */ /** 门店结算默认比例(核销金额 × 比例) */
+2
View File
@@ -183,6 +183,7 @@ enum PartnerBillStatus {
DRAFT DRAFT
CONFIRMED CONFIRMED
PAID PAID
REJECTED
} }
enum StoreStatus { enum StoreStatus {
@@ -623,6 +624,7 @@ model PartnerBill {
status PartnerBillStatus @default(DRAFT) status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3) confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict) partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
@@ -46,6 +46,7 @@ export const HqOperationAction = {
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE', PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM', PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM',
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID', PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN', REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM', REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
@@ -108,6 +109,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单', [HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单', [HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算', [HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
@@ -22,6 +22,7 @@ export class AdminDashboardService {
deliveriesTotal, deliveriesTotal,
pendingPayouts, pendingPayouts,
pendingBills, pendingBills,
pendingPartnerDraftBills,
openTickets, openTickets,
] = await Promise.all([ ] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
@@ -42,7 +43,8 @@ export class AdminDashboardService {
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }), this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(), this.prisma.orderDelivery.count(),
this.prisma.storePayout.count({ where: { status: 'PENDING' } }), this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
this.prisma.partnerBill.count({ where: { status: { in: ['DRAFT', 'CONFIRMED'] } } }), this.prisma.partnerBill.count({ where: { status: 'CONFIRMED' } }),
this.prisma.partnerBill.count({ where: { status: 'DRAFT' } }),
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
]); ]);
@@ -57,7 +59,9 @@ export class AdminDashboardService {
redeemToday, redeemToday,
deliveriesTotal, deliveriesTotal,
pendingPayouts, pendingPayouts,
/** 合伙人已确认、待总部审核打款 */
pendingBills, pendingBills,
pendingPartnerDraftBills,
openTickets, openTickets,
ordersByStatus: ordersByStatus.map((row) => ({ ordersByStatus: ordersByStatus.map((row) => ({
status: row.status, status: row.status,
@@ -25,6 +25,11 @@ export class SettlementController {
bills(@CurrentUser() user: AuthUser) { bills(@CurrentUser() user: AuthUser) {
return this.settlementService.listPartnerBills(user.actorId); return this.settlementService.listPartnerBills(user.actorId);
} }
@Post('bills/:id/confirm')
confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id));
}
} }
@Controller('shop/payouts') @Controller('shop/payouts')
@@ -193,6 +198,20 @@ export class AdminPartnerBillController {
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) { markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.markPartnerBillPaid(BigInt(id), body); return this.settlementService.markPartnerBillPaid(BigInt(id), body);
} }
@Post(':id/reject')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_REJECT,
refType: 'PARTNER_BILL',
refIdParam: 'id',
includeBody: true,
})
reject(@Param('id') id: string, @Body() body: { reason?: string }) {
if (!body?.reason?.trim()) {
throw new BadRequestException('请填写驳回理由');
}
return this.settlementService.rejectPartnerBill(BigInt(id), body.reason);
}
} }
@Controller('admin/winery-bills') @Controller('admin/winery-bills')
@@ -529,11 +529,70 @@ export class SettlementService {
async confirmPartnerBill(id: bigint) { async confirmPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在'); if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认'); if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') {
throw new BadRequestException('仅待确认或已驳回账单可确认');
}
const updated = await this.prisma.partnerBill.update({ const updated = await this.prisma.partnerBill.update({
where: { id }, where: { id },
data: { status: 'CONFIRMED', confirmedAt: new Date() }, data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
rejectReason: null,
},
});
return serializeBigInt(updated);
}
/** 合伙人端:确认账单并申请打款(DRAFT/REJECTED → CONFIRMED */
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('当前账单不可再次申请打款');
}
const updated = await this.prisma.partnerBill.update({
where: { id: billId },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
rejectReason: null,
},
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_bill_confirm',
refType: 'PARTNER_BILL',
refId: billId,
extraJson: { billNo: bill.billNo },
});
return serializeBigInt(updated);
}
async rejectPartnerBill(id: bigint, reason: string) {
const rejectReason = reason.trim();
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
if (rejectReason.length > 500) throw new BadRequestException('驳回理由不能超过 500 字');
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'CONFIRMED') {
throw new BadRequestException('仅待打款审核的账单可驳回');
}
const updated = await this.prisma.partnerBill.update({
where: { id },
data: {
status: 'REJECTED',
rejectReason,
},
}); });
return serializeBigInt(updated); return serializeBigInt(updated);
@@ -542,11 +601,11 @@ export class SettlementService {
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) { async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在'); if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款'); if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅待打款审核的账单可标记打款');
const updated = await this.prisma.partnerBill.update({ const updated = await this.prisma.partnerBill.update({
where: { id }, where: { id },
data: { status: 'PAID', paidAt: new Date() }, data: { status: 'PAID', paidAt: new Date(), rejectReason: null },
}); });
return serializeBigInt(updated); return serializeBigInt(updated);
@@ -577,6 +636,7 @@ export class SettlementService {
'状态', '状态',
'确认时间', '确认时间',
'打款时间', '打款时间',
'驳回理由',
].join(','); ].join(',');
const rows = bills.map((b) => const rows = bills.map((b) =>
[ [
@@ -591,6 +651,7 @@ export class SettlementService {
b.status, b.status,
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '', b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '', b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
csvEscape(b.rejectReason ?? ''),
].join(','), ].join(','),
); );
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };