feat(settlement): redesign factory/partner/store statement bills
CI / verify (pull_request) Has been cancelled

Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 17:25:49 +08:00
parent b534a569f8
commit b392c28787
22 changed files with 1549 additions and 543 deletions
+96 -50
View File
@@ -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<string[]>([]);
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 (
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
@@ -90,7 +107,7 @@ export default function BillsPage() {
<div className="partner-stepper-line" aria-hidden>
<div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
/>
</div>
<div className="partner-step">
@@ -100,7 +117,7 @@ export default function BillsPage() {
<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 || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
<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>
)}
@@ -108,20 +125,20 @@ export default function BillsPage() {
<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' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
{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 className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : 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">
@@ -132,7 +149,7 @@ export default function BillsPage() {
<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>
</div>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</div>
@@ -141,17 +158,17 @@ export default function BillsPage() {
<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-primary" style={{ fontWeight: 600, marginBottom: 4 }}></p>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div>
</div>
)}
{isReviewing && (
{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>
)}
@@ -178,29 +195,60 @@ export default function BillsPage() {
</section>
)}
{bills.length > 1 && (
{bills.length > 0 && (
<div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.map((b) => (
<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>
<p className="body-md">{b.billNo}</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>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
<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>
{bills.map((b) => {
const id = String(b.id);
const selectable = canConfirm(b.status);
return (
<div
key={id}
className="partner-store-card"
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
{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">{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>
</button>
</div>
</button>
))}
);
})}
</div>
)}
@@ -209,9 +257,7 @@ export default function BillsPage() {
<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 }}>
{isRejected
? '请根据驳回理由核对后重新确认并申请打款。'
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
</p>
</div>
@@ -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 && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
+2 -2
View File
@@ -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),
+12 -12
View File
@@ -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}
</p>
) : null}
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))}
</p>
</div>