feat: add commission settlement exports and proofs

This commit is contained in:
Codex
2026-06-29 21:03:57 +08:00
parent ec23cd3b9b
commit 8d4428214a
11 changed files with 706 additions and 24 deletions

View File

@@ -2,10 +2,13 @@ import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import {
createCommissionSettlementProof,
exportCommissionSettlement,
generateCommissionSettlement,
loadActivationCodes,
loadCodeBatches,
loadCommissionOrders,
loadCommissionSettlementProofs,
loadCommissionSettings,
loadCommissionSettlements,
loadCommissionSummary,
@@ -14,11 +17,13 @@ import {
loadCrmQueue,
loadTenantMembers,
updateCommissionSettings,
updateCommissionSettlementProofStatus,
updateCommissionSettlementStatus,
updateMemberCommissionRate,
upsertCrmConfig,
type CodeBatchItem,
type CommissionOrderItem,
type CommissionSettlementProofItem,
type CommissionSettlementItem,
type CommissionSettingsItem,
type CommissionSummaryItem,
@@ -51,6 +56,17 @@ function rateToPercent(value: unknown) {
return String((Number(value || 0) * 100).toFixed(2)).replace(/\.00$/, '');
}
function downloadBase64File(filename: string, contentBase64: string, mimeType: string) {
if (typeof document === 'undefined') return false;
const link = document.createElement('a');
link.href = `data:${mimeType};base64,${contentBase64}`;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
return true;
}
export default function TenantMarketingPage() {
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [batches, setBatches] = useState<CodeBatchItem[]>([]);
@@ -61,6 +77,7 @@ export default function TenantMarketingPage() {
const [commission, setCommission] = useState<CommissionSummaryItem | null>(null);
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
const [settlementProofs, setSettlementProofs] = useState<Record<string, CommissionSettlementProofItem[]>>({});
const [members, setMembers] = useState<TenantMemberItem[]>([]);
const [crmForm, setCrmForm] = useState({
enabled: false,
@@ -77,6 +94,7 @@ export default function TenantMarketingPage() {
const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' });
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
const [settlementPay, setSettlementPay] = useState({ paymentMethod: 'offline_bank', paymentAccount: '' });
const [proofForm, setProofForm] = useState({ externalUrl: '', title: '', reviewNote: '' });
const [crmStatus, setCrmStatus] = useState('');
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -292,6 +310,82 @@ export default function TenantMarketingPage() {
}
}
async function exportSettlement(item: CommissionSettlementItem) {
setBusy(`settlement:${item.id}:export`);
setError('');
try {
const payload = await exportCommissionSettlement(item.id, 'csv');
const exportItem = payload.item;
if (exportItem?.contentBase64 && exportItem.filename) {
const downloaded = downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv');
Taro.showToast({ title: downloaded ? '导出已下载' : '导出已生成', icon: 'success' });
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '结算导出失败');
} finally {
setBusy('');
}
}
async function submitProof(item: CommissionSettlementItem) {
if (!proofForm.externalUrl.trim()) {
Taro.showToast({ title: '请填写凭证链接', icon: 'none' });
return;
}
setBusy(`settlement:${item.id}:proof`);
setError('');
try {
await createCommissionSettlementProof({
settlementId: item.id,
proofType: 'payment',
title: proofForm.title.trim() || `${item.settlementNo || item.id} 打款凭证`,
externalUrl: proofForm.externalUrl.trim(),
amountCents: item.commissionAmountCents || null,
paymentMethod: settlementPay.paymentMethod || null,
paymentAccount: settlementPay.paymentAccount || null,
metadata: { source: 'taro-tenant-admin' },
});
Taro.showToast({ title: '凭证已登记', icon: 'success' });
await refreshProofs(item.id);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '凭证登记失败');
} finally {
setBusy('');
}
}
async function refreshProofs(settlementId: string) {
setBusy(`settlement:${settlementId}:proofs`);
setError('');
try {
const payload = await loadCommissionSettlementProofs(settlementId);
setSettlementProofs(prev => ({ ...prev, [settlementId]: payload.items || [] }));
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '凭证加载失败');
} finally {
setBusy('');
}
}
async function reviewProof(proof: CommissionSettlementProofItem, status: string) {
setBusy(`proof:${proof.id}:${status}`);
setError('');
try {
await updateCommissionSettlementProofStatus({
proofId: proof.id,
status,
reviewNote: proofForm.reviewNote.trim() || null,
metadata: { source: 'taro-tenant-admin' },
});
Taro.showToast({ title: '凭证状态已更新', icon: 'success' });
if (proof.settlementId) await refreshProofs(proof.settlementId);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '凭证复核失败');
} finally {
setBusy('');
}
}
return (
<View className='admin-page'>
<View className='admin-shell'>
@@ -455,20 +549,39 @@ export default function TenantMarketingPage() {
<View className='admin-form-grid'>
<Input className='admin-input' placeholder='打款方式,例如 offline_bank' value={settlementPay.paymentMethod} onInput={event => setSettlementPay(prev => ({ ...prev, paymentMethod: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='打款账户备注,可填脱敏账号' value={settlementPay.paymentAccount} onInput={event => setSettlementPay(prev => ({ ...prev, paymentAccount: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='凭证链接,例如对象存储签名后的公开凭证或内部 URL' value={proofForm.externalUrl} onInput={event => setProofForm(prev => ({ ...prev, externalUrl: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='凭证标题' value={proofForm.title} onInput={event => setProofForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='复核备注' value={proofForm.reviewNote} onInput={event => setProofForm(prev => ({ ...prev, reviewNote: String(event.detail.value || '') }))} />
</View>
<View className='admin-list'>
{settlements.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.settlementNo || item.id}</Text>
<Text className='admin-row-meta'>{item.referrerName || item.referrerPhone || item.referrerUserId} · {item.status || 'draft'} · {money(item.commissionAmountCents)}</Text>
<Text className='admin-row-meta'>{item.periodStart || '-'} {item.periodEnd || '-'} · {item.sourceCount || 0}</Text>
<View className='admin-row-actions'>
{item.status === 'pending_review' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:approved`} onClick={() => updateSettlement(item, 'approved')}></Button> : null}
{item.status === 'pending_review' ? <Button className='admin-mini-button' loading={busy === `settlement:${item.id}:rejected`} onClick={() => updateSettlement(item, 'rejected')}></Button> : null}
{item.status === 'approved' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:paid`} onClick={() => updateSettlement(item, 'paid')}></Button> : null}
{settlements.map(item => {
const proofs = settlementProofs[item.id] || [];
return (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.settlementNo || item.id}</Text>
<Text className='admin-row-meta'>{item.referrerName || item.referrerPhone || item.referrerUserId} · {item.status || 'draft'} · {money(item.commissionAmountCents)}</Text>
<Text className='admin-row-meta'>{item.periodStart || '-'} {item.periodEnd || '-'} · {item.sourceCount || 0} · {proofs.length}</Text>
<View className='admin-row-actions'>
<Button className='admin-mini-button' loading={busy === `settlement:${item.id}:export`} onClick={() => exportSettlement(item)}> CSV</Button>
<Button className='admin-mini-button' loading={busy === `settlement:${item.id}:proofs`} onClick={() => refreshProofs(item.id)}></Button>
{item.status === 'pending_review' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:approved`} onClick={() => updateSettlement(item, 'approved')}></Button> : null}
{item.status === 'pending_review' ? <Button className='admin-mini-button' loading={busy === `settlement:${item.id}:rejected`} onClick={() => updateSettlement(item, 'rejected')}></Button> : null}
{item.status === 'approved' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:paid`} onClick={() => updateSettlement(item, 'paid')}></Button> : null}
{item.status === 'paid' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:proof`} onClick={() => submitProof(item)}></Button> : null}
</View>
{proofs.map(proof => (
<View className='admin-row nested' key={proof.id}>
<Text className='admin-row-main'>{proof.title || proof.proofType || '凭证'} · {proof.status || 'submitted'}</Text>
<Text className='admin-row-meta'>{proof.externalUrl || proof.assetId || '-'} · {money(proof.amountCents)}</Text>
<View className='admin-row-actions'>
{proof.status === 'submitted' ? <Button className='admin-mini-button primary' loading={busy === `proof:${proof.id}:approved`} onClick={() => reviewProof(proof, 'approved')}></Button> : null}
{proof.status === 'submitted' ? <Button className='admin-mini-button' loading={busy === `proof:${proof.id}:rejected`} onClick={() => reviewProof(proof, 'rejected')}></Button> : null}
</View>
</View>
))}
</View>
</View>
))}
);
})}
</View>
{!settlements.length ? <View className='admin-empty'></View> : null}
</View>

View File

@@ -511,6 +511,41 @@ export interface CommissionSettlementItem {
updatedAt?: string;
}
export interface CommissionSettlementExportItem {
settlementId?: string;
filename?: string;
format?: 'csv' | 'json';
mimeType?: string;
rowCount?: number;
contentBase64?: string;
sha256?: string;
sizeBytes?: number;
}
export interface CommissionSettlementProofItem {
id: string;
settlementId?: string;
proofType?: string;
status?: string;
title?: string | null;
description?: string | null;
assetId?: string | null;
externalUrl?: string | null;
amountCents?: number | null;
paymentMethod?: string | null;
paymentAccount?: string | null;
paidAt?: string | null;
submittedBy?: string | null;
submittedByName?: string | null;
reviewedBy?: string | null;
reviewedByName?: string | null;
reviewedAt?: string | null;
reviewNote?: string | null;
metadata?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface TenantOverview {
id?: string;
name?: string;
@@ -1078,6 +1113,18 @@ export async function loadCommissionSettlements(query: { status?: string; referr
});
}
export async function exportCommissionSettlement(settlementId: string, format: 'csv' | 'json' = 'csv') {
return apiRequest<{ item?: CommissionSettlementExportItem }>('/api/commission/settlements/export', {
query: { settlementId, format },
});
}
export async function loadCommissionSettlementProofs(settlementId: string) {
return apiRequest<{ items?: CommissionSettlementProofItem[] }>('/api/commission/settlements/proofs', {
query: { settlementId },
});
}
export async function generateCommissionSettlement(input: {
referrerUserId: string;
startDate: string;
@@ -1104,3 +1151,34 @@ export async function updateCommissionSettlementStatus(input: {
body: input,
});
}
export async function createCommissionSettlementProof(input: {
settlementId: string;
proofType?: string;
title?: string | null;
description?: string | null;
assetId?: string | null;
externalUrl?: string | null;
amountCents?: number | null;
paymentMethod?: string | null;
paymentAccount?: string | null;
paidAt?: string | null;
metadata?: Record<string, unknown>;
}) {
return apiRequest<{ item?: CommissionSettlementProofItem }>('/api/commission/settlements/proofs', {
method: 'POST',
body: input,
});
}
export async function updateCommissionSettlementProofStatus(input: {
proofId: string;
status: string;
reviewNote?: string | null;
metadata?: Record<string, unknown>;
}) {
return apiRequest<{ item?: CommissionSettlementProofItem }>('/api/commission/settlements/proofs/status', {
method: 'POST',
body: input,
});
}