feat(settlement): 合伙人账单生成后直接同步并支持打款凭证

去掉总部发送与合伙人确认,财务打款后上传凭证,合伙人即可查看状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-26 13:04:03 +08:00
parent cb63d382ad
commit 550697c346
15 changed files with 256 additions and 541 deletions
+66 -149
View File
@@ -1,11 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDetailDto, PartnerBillDto, PartnerBillItemDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { formatBillPeriod } from '../lib/settlement';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
@@ -14,16 +13,34 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) {
switch (status) {
case 'AWAITING_CONFIRM': return '待确认';
case 'UNPAID': return '未打款';
case 'PAID': return '已打款';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function canConfirm(status: string) {
return status === 'AWAITING_CONFIRM';
function PaymentProofs({ urls, paymentRef }: { urls?: string[]; paymentRef?: string | null }) {
const list = (urls ?? []).filter(Boolean);
if (!list.length && !paymentRef) return null;
return (
<div style={{ marginTop: 16 }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}>打款凭证</p>
{paymentRef ? <p className="body-md" style={{ marginBottom: 8 }}>凭证号 {paymentRef}</p> : null}
{list.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{list.map((url) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img
src={url}
alt="打款凭证"
style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 6, display: 'block' }}
/>
</a>
))}
</div>
)}
</div>
);
}
export default function BillsPage() {
@@ -31,10 +48,7 @@ export default function BillsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detail, setDetail] = useState<PartnerBillDetailDto | null>(null);
async function loadBills() {
@@ -55,18 +69,12 @@ export default function BillsPage() {
}, [navigate]);
const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canConfirm(b.status)),
[bills],
);
const bill = billId
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: actionable[0] ?? bills[0];
? bills.find((b) => String(b.id) === billId) ?? bills[0]
: bills.find((b) => b.status === 'UNPAID') ?? bills[0];
const status = String(bill?.status || '');
const showApplyForm = !!bill && canConfirm(status);
const isUnpaid = status === 'UNPAID';
const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID';
useEffect(() => {
@@ -107,80 +115,42 @@ export default function BillsPage() {
);
}
function askConfirm(ids: string[]) {
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
return;
}
void doConfirm(ids);
}
async function doConfirm(ids: string[]) {
setSubmitting(true);
try {
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 : '提交失败');
} finally {
setSubmitting(false);
}
}
function toggleSelect(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
const proofUrls = detail?.paymentProofUrls ?? bill?.paymentProofUrls;
const paymentRef = detail?.paymentRef ?? bill?.paymentRef;
return (
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<PageHeader title="我的账单" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper">
<div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden>
<div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
style={{ width: isPaid ? '100%' : '50%' }}
/>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
<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 className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || 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>
</div>
<div className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
) : '2'}
</div>
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
<span className={`partner-step-label${isUnpaid || isPaid ? ' partner-step-label--active' : ''}`}>
{isPaid ? '已打款' : '财务打款'}
</span>
</div>
</div>
</div>
{loading && <div className="empty">加载中…</div>}
{!loading && !bill && <div className="empty">暂无待确认账单</div>}
{!loading && !bill && <div className="empty">暂无账单</div>}
{bill && (
<section className="partner-bill-card">
@@ -192,26 +162,25 @@ export default function BillsPage() {
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '周结算账单')}</h2>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{formatBillPeriod(bill.periodStart, bill.periodEnd)}</p>
</div>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
<span className={`partner-status-pill${isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</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>
)}
{isUnpaid && (
<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>
)}
{isPaid && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
财务已打款{bill.paidAt ? `(${String(bill.paidAt).slice(0, 10)})` : ''}。
</p>
</div>
)}
@@ -226,94 +195,42 @@ export default function BillsPage() {
{renderItemList('酒订单', detail?.orderItems, Number(detail?.orderCommission ?? bill.orderCommission ?? 0))}
{renderItemList('核销订单', detail?.redeemItems, Number(detail?.redeemCommission ?? bill.redeemCommission ?? 0))}
{isPaid && <PaymentProofs urls={proofUrls} paymentRef={paymentRef} />}
</div>
</section>
)}
{bills.length > 0 && (
<div style={{ padding: '0 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 className="headline-md" style={{ margin: 0 }}>全部账单</h3>
{actionable.length > 1 && (
<button
type="button"
className="partner-fill-max"
style={{ fontSize: 13 }}
disabled={!selectedIds.length || submitting}
onClick={() => askConfirm(selectedIds)}
>
批量确认 ({selectedIds.length})
</button>
)}
</div>
<div style={{ padding: '0 20px 24px' }}>
<h3 className="headline-md" style={{ margin: '0 0 12px' }}>全部账单</h3>
{bills.map((b) => {
const id = String(b.id);
const selectable = canConfirm(b.status);
const active = bill && String(bill.id) === id;
return (
<div
<button
key={id}
type="button"
className="partner-store-card"
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
style={{
margin: '0 0 12px',
width: '100%',
textAlign: 'left',
border: active ? '1px solid var(--md-sys-color-primary, #a61d24)' : undefined,
}}
onClick={() => navigate(`/center/bills?id=${id}`)}
>
{selectable ? (
<input
type="checkbox"
checked={selectedIds.includes(id)}
onChange={() => toggleSelect(id)}
style={{ marginTop: 4 }}
/>
) : (
<span style={{ width: 16 }} />
)}
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{formatBillPeriod(b.periodStart, b.periodEnd)} · {billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>驳回:{b.rejectReason}</p>
) : null}
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{formatBillPeriod(b.periodStart, b.periodEnd)} · {billStatusLabel(b.status)}</p>
</div>
</button>
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div>
</button>
);
})}
</div>
)}
{showApplyForm && (
<>
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
确认后账单变为「未打款」,由总部完成打款。如有异议请先联系城市运营经理。
</p>
</div>
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span>我已核对数据无误,同意根据此账单进行结算</span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={() => askConfirm([String(bill!.id)])}
>
{submitting ? '正在提交...' : '确认账单'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
</>
)}
</PullToRefresh>
);
}
+7 -16
View File
@@ -62,19 +62,14 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
}, [loadCenter]);
const finance = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
const unpaid = bills.filter((b) => b.status === 'UNPAID');
const settled = bills.filter((b) => b.status === 'PAID');
const paidCount = settled.length;
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
return {
pendingBillCount: pending.length,
rejectedCount,
pendingTotal: bills
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED')
.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
unpaidCount: unpaid.length,
pendingTotal: unpaid.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),
paidCount,
paidCount: settled.length,
};
}, [bills]);
@@ -167,20 +162,16 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
</Link>
</div>
{finance.pendingBillCount > 0 && (
{finance.unpaidCount > 0 && (
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">receipt_long</span>
</div>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600 }}>
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
</p>
<p className="body-md text-primary" style={{ fontWeight: 600 }}>待打款账单</p>
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
{finance.rejectedCount > 0
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
您有 {finance.unpaidCount} 笔账单待财务打款
</p>
</div>
</div>
+8 -21
View File
@@ -6,7 +6,7 @@ import type { PartnerBillDto, PartnerSettlementPreviewDto } from '@dukang/shared
import { isLoggedIn, request } from '../lib/api';
import { formatBillPeriod, formatIssueLabel } from '../lib/settlement';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
type StatusFilter = 'all' | 'unpaid' | 'paid';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -14,20 +14,16 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) {
switch (status) {
case 'AWAITING_CONFIRM': return '待确认';
case 'UNPAID': return '未打款';
case 'PAID': return '已打款';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function billStatusClass(status: string) {
switch (status) {
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 '';
}
}
@@ -57,10 +53,10 @@ export default function SettlementPage() {
}, [navigate, loadBills]);
const summary = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
const unpaid = bills.filter((b) => b.status === 'UNPAID');
const settled = bills.filter((b) => b.status === 'PAID');
return {
pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
pendingTotal: unpaid.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
};
}, [bills]);
@@ -68,10 +64,8 @@ export default function SettlementPage() {
const filteredBills = useMemo(() => {
return bills.filter((b) => {
if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'UNPAID';
if (statusFilter === 'rejected') return b.status === 'REJECTED';
if (statusFilter === 'unpaid') return b.status === 'UNPAID';
if (statusFilter === 'paid') return b.status === 'PAID';
return true;
});
}, [bills, statusFilter]);
@@ -82,10 +76,8 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待确认' },
{ key: 'reviewing', label: '未打款' },
{ key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已打款' },
{ key: 'unpaid', label: '未打款' },
{ key: 'paid', label: '已打款' },
];
return (
@@ -164,12 +156,7 @@ export default function SettlementPage() {
<span className="partner-settlement-dot" />
订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}
</p>
{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 === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
<p className={`partner-settlement-item-amount${bill.status === 'UNPAID' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))}
</p>
</div>