forked from wangziqi/gongxue-base
feat: add platform subscription invoice batching
This commit is contained in:
@@ -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'>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user