feat: add platform invoice dunning workflow

This commit is contained in:
Codex
2026-06-30 05:27:58 +08:00
parent f9f96bee63
commit c74432ce98
25 changed files with 858 additions and 23 deletions

View File

@@ -7,11 +7,14 @@ import {
createPlatformInvoiceFromSubscription,
createPlatformInvoicesBatchFromSubscriptions,
createPlatformSubscription,
loadPlatformInvoiceReminders,
loadPlatformInvoices,
loadPlatformPlans,
loadPlatformSubscriptionInvoiceCandidates,
loadPlatformUsage,
processPlatformOverdueInvoices,
recordPlatformUsage,
type PlatformInvoiceReminderItem,
type PlatformInvoiceItem,
type PlatformSaasPlan,
type PlatformSubscriptionInvoiceCandidate,
@@ -38,6 +41,7 @@ export default function PlatformBillingPage() {
const [status, setStatus] = useState('');
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [reminders, setReminders] = useState<PlatformInvoiceReminderItem[]>([]);
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
const [batchResult, setBatchResult] = useState('');
@@ -77,13 +81,15 @@ export default function PlatformBillingPage() {
Promise.all([
loadPlatformPlans(true).catch(() => ({ items: [] })),
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
loadPlatformInvoiceReminders({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, usagePayload, candidatePayload]) => {
]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload]) => {
const nextPlans = planPayload.items || [];
setPlans(nextPlans);
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
setInvoices(invoicePayload.items || []);
setReminders(reminderPayload.items || []);
setUsage(usagePayload.items || []);
setCandidates(candidatePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
@@ -283,6 +289,32 @@ export default function PlatformBillingPage() {
}
}
async function submitOverdueProcess(dryRun: boolean) {
setError('');
const ok = dryRun
? true
: await confirm('处理逾期账单', '确认扫描已过期未结清的平台服务费账单,并生成内部催缴记录?该动作不会自动停用租户。');
if (!ok) return;
setBusy(dryRun ? 'overdue-dry-run' : 'overdue-process');
try {
const payload = await processPlatformOverdueInvoices({
dryRun,
channel: 'internal',
limit: 100,
});
const item = payload.item || {};
setBatchResult(`${dryRun ? '逾期预览' : '逾期处理'}完成:处理 ${item.processed || 0},标记 ${item.markedOverdue || 0},催缴 ${item.reminderCreated || 0},跳过 ${item.skippedReminder || 0}`);
if (!dryRun) {
Taro.showToast({ title: '已处理', icon: 'success' });
reload(status);
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '逾期处理失败');
} finally {
setBusy('');
}
}
return (
<View className='platform-page'>
<View className='platform-shell'>
@@ -381,6 +413,25 @@ export default function PlatformBillingPage() {
{!candidates.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-actions'>
<Button className='platform-button' loading={busy === 'overdue-dry-run'} onClick={() => submitOverdueProcess(true)}></Button>
<Button className='platform-button primary' loading={busy === 'overdue-process'} onClick={() => submitOverdueProcess(false)}></Button>
</View>
<View className='platform-list'>
{reminders.slice(0, 12).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.invoiceNo || item.invoiceId}</Text>
<Text className='platform-row-meta'>{item.tenantName || item.tenantSlug || item.tenantId} · {item.reminderType || 'reminder'} · {item.channel || 'internal'} · {item.status || '-'}</Text>
<Text className='platform-row-meta'> {String(item.reminderLevel || 1)} · {item.dueDate ? String(item.dueDate).slice(0, 10) : '-'} · {money(item.balanceCentsSnapshot)}</Text>
<Text className='platform-row-meta'>{item.message || '暂无催缴备注'}</Text>
</View>
))}
</View>
{!reminders.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'>SaaS </Text>
<View className='platform-list'>

View File

@@ -126,6 +126,24 @@ export interface PlatformInvoiceItem {
note?: string | null;
}
export interface PlatformInvoiceReminderItem {
id: string;
tenantId: string;
tenantSlug?: string | null;
tenantName?: string | null;
invoiceId: string;
invoiceNo?: string | null;
reminderType?: string | null;
channel?: string | null;
status?: string | null;
reminderDate?: string | null;
reminderLevel?: number | string | null;
dueDate?: string | null;
balanceCentsSnapshot?: number | string | null;
message?: string | null;
createdAt?: string | null;
}
export interface PlatformUsageItem {
id: string;
tenantId: string;
@@ -265,6 +283,12 @@ export interface ConfirmPlatformInvoicePaymentInput {
providerTradeNo?: string;
}
export interface ProcessPlatformOverdueInvoicesInput {
dryRun?: boolean;
channel?: string;
limit?: number;
}
export interface RecordPlatformUsageInput {
tenantId: string;
metricKey: string;
@@ -417,6 +441,34 @@ export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoic
});
}
export async function processPlatformOverdueInvoices(input: ProcessPlatformOverdueInvoicesInput = {}) {
return apiRequest<{
item?: {
dryRun?: boolean;
processed?: number;
markedOverdue?: number;
reminderCreated?: number;
skippedReminder?: number;
items?: Array<PlatformInvoiceItem & {
wouldMarkOverdue?: boolean;
wouldCreateReminder?: boolean;
reminderCreated?: boolean;
}>;
};
}>('/api/platform-admin/invoices/process-overdue', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function loadPlatformInvoiceReminders(query: { tenantId?: string; invoiceId?: string; status?: string; reminderType?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceReminderItem[] }>('/api/platform-admin/invoices/reminders', {
query: { ...query, limit: query.limit || 100 },
tenantId: null,
});
}
export async function recordPlatformUsage(input: RecordPlatformUsageInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/usage', {
method: 'POST',