feat: add platform subscription invoice batching

This commit is contained in:
Codex
2026-06-30 04:56:28 +08:00
parent d5d32e84b3
commit 5320558d13
16 changed files with 670 additions and 126 deletions

View File

@@ -5,13 +5,16 @@ import { Input } from '@tarojs/components';
import {
confirmPlatformInvoicePayment,
createPlatformInvoiceFromSubscription,
createPlatformInvoicesBatchFromSubscriptions,
createPlatformSubscription,
loadPlatformInvoices,
loadPlatformPlans,
loadPlatformSubscriptionInvoiceCandidates,
loadPlatformUsage,
recordPlatformUsage,
type PlatformInvoiceItem,
type PlatformSaasPlan,
type PlatformSubscriptionInvoiceCandidate,
type PlatformUsageItem,
} from '@/services/platformAdmin';
import '../platform.css';
@@ -36,6 +39,8 @@ export default function PlatformBillingPage() {
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
const [batchResult, setBatchResult] = useState('');
const [subscriptionForm, setSubscriptionForm] = useState({
tenantId: '',
planCode: '',
@@ -60,6 +65,11 @@ export default function PlatformBillingPage() {
periodStart: todayText(),
periodEnd: todayText(),
});
const [batchInvoiceForm, setBatchInvoiceForm] = useState({
daysAhead: '45',
dueDate: '',
note: '',
});
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -68,12 +78,14 @@ export default function PlatformBillingPage() {
loadPlatformPlans(true).catch(() => ({ items: [] })),
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, usagePayload]) => {
loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, usagePayload, candidatePayload]) => {
const nextPlans = planPayload.items || [];
setPlans(nextPlans);
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
setInvoices(invoicePayload.items || []);
setUsage(usagePayload.items || []);
setCandidates(candidatePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
}
@@ -105,6 +117,10 @@ export default function PlatformBillingPage() {
setUsageForm(current => ({ ...current, [key]: value }));
}
function updateBatchInvoiceForm(key: keyof typeof batchInvoiceForm, value: string) {
setBatchInvoiceForm(current => ({ ...current, [key]: value }));
}
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
@@ -215,6 +231,58 @@ export default function PlatformBillingPage() {
}
}
async function loadSubscriptionCandidates() {
setError('');
setBusy('candidates');
try {
const payload = await loadPlatformSubscriptionInvoiceCandidates({
daysAhead: Number(batchInvoiceForm.daysAhead || 45),
limit: 100,
});
setCandidates(payload.items || []);
setBatchResult('');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '订阅账单候选加载失败');
} finally {
setBusy('');
}
}
async function submitBatchInvoices(dryRun: boolean) {
setError('');
const daysAhead = Number(batchInvoiceForm.daysAhead || 45);
if (!Number.isFinite(daysAhead) || daysAhead < 0) {
setError('请填写有效的候选天数。');
return;
}
const ok = dryRun
? true
: await confirm('批量生成账单', `确认给 ${candidates.length} 个候选订阅批量生成服务费账单?后端会跳过已存在账单的订阅。`);
if (!ok) return;
setBusy(dryRun ? 'batch-dry-run' : 'batch-create');
try {
const payload = await createPlatformInvoicesBatchFromSubscriptions({
daysAhead,
dueDate: batchInvoiceForm.dueDate || undefined,
note: batchInvoiceForm.note.trim() || undefined,
status: 'issued',
dryRun,
});
const item = payload.item || {};
setBatchResult(`${dryRun ? '预览' : '生成'}完成:创建 ${item.createdCount || 0},跳过 ${item.skippedCount || 0}`);
if (dryRun) {
setCandidates((item.items || []).map(candidate => ({ ...candidate, wouldCreate: true })));
} else {
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'>
@@ -288,6 +356,31 @@ export default function PlatformBillingPage() {
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='45' type='number' value={batchInvoiceForm.daysAhead} onInput={event => updateBatchInvoiceForm('daysAhead', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD可选' value={batchInvoiceForm.dueDate} onInput={event => updateBatchInvoiceForm('dueDate', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='账单说明,可选' value={batchInvoiceForm.note} onInput={event => updateBatchInvoiceForm('note', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button' loading={busy === 'candidates'} onClick={loadSubscriptionCandidates}></Button>
<Button className='platform-button' loading={busy === 'batch-dry-run'} onClick={() => submitBatchInvoices(true)}></Button>
<Button className='platform-button primary' loading={busy === 'batch-create'} onClick={() => submitBatchInvoices(false)}></Button>
</View>
{batchResult ? <Text className='platform-row-meta'>{batchResult}</Text> : null}
<View className='platform-list'>
{candidates.slice(0, 12).map(item => (
<View className='platform-row' key={item.subscriptionId}>
<Text className='platform-row-main'>{item.tenantName || item.tenantSlug || item.tenantId}</Text>
<Text className='platform-row-meta'>{item.planName || item.planCode || 'plan'} · {item.status || '-'} · {money(item.amountCents)} · {String(item.expiresAt || '').slice(0, 10) || '-'}</Text>
<Text className='platform-row-meta'>{item.hasExistingInvoice ? `已有账单 ${item.existingInvoiceNo || item.existingInvoiceId}` : item.wouldCreate ? '预览会生成' : '可生成订阅服务费账单'}</Text>
</View>
))}
</View>
{!candidates.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

@@ -138,6 +138,26 @@ export interface PlatformUsageItem {
metadata?: Record<string, unknown> | null;
}
export interface PlatformSubscriptionInvoiceCandidate {
tenantId: string;
tenantSlug?: string | null;
tenantName?: string | null;
billingStatus?: string | null;
subscriptionId: string;
planCode?: string | null;
planName?: string | null;
status?: string | null;
startsAt?: string | null;
expiresAt?: string | null;
billingCycle?: string | null;
amountCents?: number | string | null;
existingInvoiceId?: string | null;
existingInvoiceNo?: string | null;
existingInvoiceStatus?: string | null;
hasExistingInvoice?: boolean | null;
wouldCreate?: boolean | null;
}
export interface PlatformQuestionBankItem {
id: string;
tenantId?: string | null;
@@ -226,6 +246,16 @@ export interface CreatePlatformInvoiceFromSubscriptionInput {
note?: string;
}
export interface CreatePlatformInvoicesBatchFromSubscriptionsInput {
tenantIds?: string[];
subscriptionIds?: string[];
daysAhead?: number;
status?: string;
dueDate?: string;
note?: string;
dryRun?: boolean;
}
export interface ConfirmPlatformInvoicePaymentInput {
tenantId: string;
invoiceId: string;
@@ -302,6 +332,13 @@ export async function loadPlatformUsage(query: { tenantId?: string; limit?: numb
});
}
export async function loadPlatformSubscriptionInvoiceCandidates(query: { tenantIds?: string; subscriptionIds?: string; daysAhead?: number; includeExisting?: boolean; limit?: number } = {}) {
return apiRequest<{ items?: PlatformSubscriptionInvoiceCandidate[] }>('/api/platform-admin/invoices/subscription-candidates', {
query: { ...query, limit: query.limit || 100 },
tenantId: null,
});
}
export async function loadPlatformQuestionBanks(query: { q?: string; status?: string; includeTenantBanks?: boolean; limit?: number } = {}) {
return apiRequest<{ items?: PlatformQuestionBankItem[] }>('/api/platform-admin/question-banks', {
query: { status: 'active', ...query, limit: query.limit || 80 },
@@ -356,6 +393,22 @@ export async function createPlatformInvoiceFromSubscription(input: CreatePlatfor
});
}
export async function createPlatformInvoicesBatchFromSubscriptions(input: CreatePlatformInvoicesBatchFromSubscriptionsInput) {
return apiRequest<{
item?: {
dryRun?: boolean;
createdCount?: number;
skippedCount?: number;
items?: Array<PlatformSubscriptionInvoiceCandidate & { invoice?: PlatformInvoiceItem }>;
skipped?: Array<Record<string, unknown>>;
};
}>('/api/platform-admin/invoices/from-subscriptions-batch', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/payments/manual-confirm', {
method: 'POST',