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

@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
@@ -5,6 +6,8 @@ import { query, queryOne, transaction } from '../../core/db.js';
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
const SETTLEMENT_STATUSES = ['draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled'];
const PROOF_TYPES = ['payment', 'invoice', 'receipt', 'adjustment', 'other'];
const PROOF_STATUSES = ['submitted', 'approved', 'rejected', 'voided'];
const RATE_SOURCE_ORDER: Record<string, number> = { batch: 1, member: 2, default: 3 };
type JsonBody = Record<string, unknown>;
@@ -148,6 +151,76 @@ function rowToCommissionItem(row: Record<string, unknown>) {
};
}
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
const text = String(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function csvLine(values: unknown[]) {
return values.map(csvEscape).join(',');
}
function contentBase64AndHash(content: string) {
const buffer = Buffer.from(content, 'utf8');
return {
contentBase64: buffer.toString('base64'),
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
sizeBytes: buffer.length,
};
}
function proofTitle(value: unknown, fallback: string) {
const title = nullableString(value);
if (!title) return fallback;
return title.length > 120 ? title.slice(0, 120) : title;
}
function externalProofUrl(value: unknown) {
const url = nullableString(value);
if (!url) return null;
if (url.length > 1000) {
throw new HttpError(400, 'Proof URL is too long', 'INVALID_PROOF_URL');
}
try {
const parsed = new URL(url);
if (!['https:', 'http:'].includes(parsed.protocol)) {
throw new Error('unsupported protocol');
}
return parsed.toString();
} catch {
throw new HttpError(400, 'Proof URL must be a valid http(s) URL', 'INVALID_PROOF_URL');
}
}
function optionalTimestamp(value: unknown, fieldName: string) {
const text = nullableString(value);
if (!text) return null;
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) {
throw new HttpError(400, `${fieldName} must be a valid timestamp`, 'INVALID_TIMESTAMP');
}
return parsed.toISOString();
}
async function assertTenantAsset(client: pg.PoolClient, tenantId: string, assetId: string | null) {
if (!assetId) return null;
const result = await client.query<{ id: string }>(
`
select id
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[tenantId, assetId],
);
if (!result.rows[0]) {
throw new HttpError(400, 'Proof asset does not belong to this tenant', 'COMMISSION_PROOF_ASSET_INVALID');
}
return assetId;
}
function summarizeCommissionRows(rows: Record<string, unknown>[]) {
const seenUsers = new Set<string>();
const byReferrer = new Map<string, {
@@ -565,6 +638,294 @@ export async function commissionSettlementsRoute(ctx: RequestContext) {
return { items };
}
async function settlementForScope(auth: TenantAdminAuth, settlementId: string) {
const item = await queryOne<Record<string, unknown>>(
`
select cs.id, cs.settlement_no as "settlementNo", cs.referrer_user_id as "referrerUserId",
u.name as "referrerName", u.phone as "referrerPhone", cs.status,
cs.period_start as "periodStart", cs.period_end as "periodEnd",
cs.source_count as "sourceCount", cs.paid_user_count as "paidUserCount",
cs.gross_amount_cents as "grossAmountCents",
cs.commission_amount_cents as "commissionAmountCents",
cs.default_rate as "defaultRate", cs.effective_rate as "effectiveRate",
cs.reviewed_by as "reviewedBy", cs.reviewed_at as "reviewedAt",
cs.paid_by as "paidBy", cs.paid_at as "paidAt",
cs.payment_method as "paymentMethod", cs.payment_account as "paymentAccount",
cs.remark, cs.metadata, cs.created_at as "createdAt", cs.updated_at as "updatedAt"
from public.commission_settlements cs
join public.platform_users u on u.id = cs.referrer_user_id
where cs.tenant_id = $1 and cs.id = $2
limit 1
`,
[auth.tenantId, settlementId],
);
if (!item) throw new HttpError(404, 'Commission settlement not found', 'COMMISSION_SETTLEMENT_NOT_FOUND');
restrictReferrerScope(auth, String(item.referrerUserId));
return item;
}
async function settlementItems(tenantId: string, settlementId: string) {
return query<Record<string, unknown>>(
`
select csi.id, csi.source_type as "sourceType", csi.source_id as "sourceId",
csi.source_no as "sourceNo", csi.source_paid_at as "sourcePaidAt",
csi.student_user_id as "studentUserId", student.name as "studentName",
student.phone as "studentPhone", csi.referrer_user_id as "referrerUserId",
referrer.name as "referrerName", referrer.phone as "referrerPhone",
csi.gross_amount_cents as "grossAmountCents",
csi.commission_rate as "commissionRate",
csi.commission_amount_cents as "commissionAmountCents",
csi.rate_source as "rateSource", csi.attribution_type as "attributionType",
csi.metadata, csi.created_at as "createdAt"
from public.commission_settlement_items csi
join public.platform_users referrer on referrer.id = csi.referrer_user_id
left join public.platform_users student on student.id = csi.student_user_id
where csi.tenant_id = $1 and csi.settlement_id = $2
order by csi.source_paid_at asc nulls last, csi.source_no asc nulls last
`,
[tenantId, settlementId],
);
}
export async function commissionSettlementExportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionRead(auth);
const settlementId = stringParam(ctx, 'settlementId');
if (!settlementId) throw new HttpError(400, 'settlementId is required', 'REQUIRED_FIELD');
const format = optionalChoice(ctx.url.searchParams.get('format'), ['csv', 'json'], 'csv');
const settlement = await settlementForScope(auth, settlementId);
const rows = await settlementItems(auth.tenantId, settlementId);
const filename = `${settlement.settlementNo || settlementId}.${format}`;
const payload = {
settlement,
items: rows.map(rowToCommissionItem),
exportedAt: new Date().toISOString(),
};
const content = format === 'json'
? JSON.stringify(payload, null, 2)
: [
csvLine([
'settlementNo',
'referrerName',
'referrerPhone',
'periodStart',
'periodEnd',
'sourceType',
'sourceNo',
'sourcePaidAt',
'studentName',
'studentPhone',
'grossAmountCents',
'commissionRate',
'commissionAmountCents',
'rateSource',
]),
...rows.map(row => csvLine([
settlement.settlementNo,
settlement.referrerName,
settlement.referrerPhone,
settlement.periodStart,
settlement.periodEnd,
row.sourceType,
row.sourceNo,
row.sourcePaidAt,
row.studentName,
row.studentPhone,
row.grossAmountCents,
row.commissionRate,
row.commissionAmountCents,
row.rateSource,
])),
].join('\n');
const encoded = contentBase64AndHash(content);
await transaction(async client => {
await client.query(
`
insert into public.commission_settlement_export_events (
tenant_id, settlement_id, export_format, filename, row_count, content_sha256, exported_by, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
auth.tenantId,
settlementId,
format,
filename,
rows.length,
encoded.sha256,
auth.userId,
JSON.stringify({ sizeBytes: encoded.sizeBytes }),
],
);
await recordAudit(client, auth, 'commission.settlement.exported', 'commission_settlements', settlementId, {
format,
filename,
rowCount: rows.length,
sha256: encoded.sha256,
});
});
return {
item: {
settlementId,
filename,
format,
mimeType: format === 'json' ? 'application/json' : 'text/csv',
rowCount: rows.length,
...encoded,
},
};
}
export async function commissionSettlementProofsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionRead(auth);
const settlementId = stringParam(ctx, 'settlementId');
if (!settlementId) throw new HttpError(400, 'settlementId is required', 'REQUIRED_FIELD');
await settlementForScope(auth, settlementId);
const items = await query(
`
select p.id, p.settlement_id as "settlementId", p.proof_type as "proofType", p.status,
p.title, p.description, p.asset_id as "assetId", p.external_url as "externalUrl",
p.amount_cents as "amountCents", p.payment_method as "paymentMethod",
p.payment_account as "paymentAccount", p.paid_at as "paidAt",
p.submitted_by as "submittedBy", submitter.name as "submittedByName",
p.reviewed_by as "reviewedBy", reviewer.name as "reviewedByName",
p.reviewed_at as "reviewedAt", p.review_note as "reviewNote",
p.metadata, p.created_at as "createdAt", p.updated_at as "updatedAt"
from public.commission_settlement_proofs p
left join public.platform_users submitter on submitter.id = p.submitted_by
left join public.platform_users reviewer on reviewer.id = p.reviewed_by
where p.tenant_id = $1 and p.settlement_id = $2
order by p.created_at desc
`,
[auth.tenantId, settlementId],
);
return { items };
}
export async function createCommissionSettlementProofRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canReviewCommission(auth)) {
throw new HttpError(403, 'Commission review permission is required', 'TENANT_PERMISSION_REQUIRED');
}
const body = await readJsonBody(ctx);
const settlementId = requiredString(body, 'settlementId');
const settlement = await settlementForScope(auth, settlementId);
const proofType = optionalChoice(body.proofType, PROOF_TYPES, 'payment');
const amountCents = body.amountCents === undefined || body.amountCents === null
? intValue(settlement.commissionAmountCents)
: Math.max(0, intValue(body.amountCents));
const externalUrl = externalProofUrl(body.externalUrl);
const paidAt = optionalTimestamp(body.paidAt, 'paidAt');
const item = await transaction(async client => {
const assetId = await assertTenantAsset(client, auth.tenantId, nullableString(body.assetId));
const result = await client.query(
`
insert into public.commission_settlement_proofs (
tenant_id, settlement_id, proof_type, status, title, description,
asset_id, external_url, amount_cents, payment_method, payment_account,
paid_at, submitted_by, metadata
)
values ($1, $2, $3, 'submitted', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
returning id, settlement_id as "settlementId", proof_type as "proofType", status,
title, description, asset_id as "assetId", external_url as "externalUrl",
amount_cents as "amountCents", payment_method as "paymentMethod",
payment_account as "paymentAccount", paid_at as "paidAt",
submitted_by as "submittedBy", metadata, created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
settlementId,
proofType,
proofTitle(body.title, `${settlement.settlementNo || settlementId} 打款凭证`),
nullableString(body.description),
assetId,
externalUrl,
amountCents,
nullableString(body.paymentMethod),
nullableString(body.paymentAccount),
paidAt,
auth.userId,
JSON.stringify(objectValue(body.metadata)),
],
);
await recordAudit(client, auth, 'commission.settlement.proof_submitted', 'commission_settlement_proofs', result.rows[0].id, {
settlementId,
proofType,
amountCents,
hasAsset: Boolean(assetId),
hasExternalUrl: Boolean(externalUrl),
});
return result.rows[0];
});
return { item };
}
export async function updateCommissionSettlementProofStatusRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canReviewCommission(auth)) {
throw new HttpError(403, 'Commission review permission is required', 'TENANT_PERMISSION_REQUIRED');
}
const body = await readJsonBody(ctx);
const proofId = requiredString(body, 'proofId');
const status = optionalChoice(body.status, PROOF_STATUSES, 'approved');
if (status === 'submitted') {
throw new HttpError(400, 'Cannot move proof back to submitted through status API', 'INVALID_PROOF_STATUS');
}
const item = await transaction(async client => {
const current = await client.query<{ id: string; settlementId: string; status: string }>(
`
select id, settlement_id as "settlementId", status
from public.commission_settlement_proofs
where tenant_id = $1 and id = $2
limit 1
for update
`,
[auth.tenantId, proofId],
);
const proof = current.rows[0];
if (!proof) throw new HttpError(404, 'Commission settlement proof not found', 'COMMISSION_PROOF_NOT_FOUND');
await settlementForScope(auth, proof.settlementId);
if (['approved', 'rejected', 'voided'].includes(proof.status) && proof.status !== status) {
throw new HttpError(409, 'Closed proof cannot change status', 'COMMISSION_PROOF_CLOSED');
}
const result = await client.query(
`
update public.commission_settlement_proofs
set status = $3,
reviewed_by = $4,
reviewed_at = now(),
review_note = coalesce($5, review_note),
metadata = metadata || $6::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, settlement_id as "settlementId", proof_type as "proofType", status,
title, description, asset_id as "assetId", external_url as "externalUrl",
amount_cents as "amountCents", payment_method as "paymentMethod",
payment_account as "paymentAccount", paid_at as "paidAt",
submitted_by as "submittedBy", reviewed_by as "reviewedBy",
reviewed_at as "reviewedAt", review_note as "reviewNote",
metadata, created_at as "createdAt", updated_at as "updatedAt"
`,
[auth.tenantId, proofId, status, auth.userId, nullableString(body.reviewNote), JSON.stringify(objectValue(body.metadata))],
);
await recordAudit(client, auth, 'commission.settlement.proof_status_updated', 'commission_settlement_proofs', proofId, {
settlementId: proof.settlementId,
status,
});
return result.rows[0];
});
return { item };
}
export async function generateCommissionSettlementRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionWrite(auth);

View File

@@ -2,10 +2,14 @@ import type { RouteDefinition } from '../../core/router.js';
import {
commissionOrdersRoute,
commissionSettingsRoute,
commissionSettlementExportRoute,
commissionSettlementProofsRoute,
commissionSettlementsRoute,
commissionSummaryRoute,
createCommissionSettlementProofRoute,
generateCommissionSettlementRoute,
updateCommissionSettingsRoute,
updateCommissionSettlementProofStatusRoute,
updateCommissionSettlementStatusRoute,
updateMemberCommissionRateRoute,
} from './commission.js';
@@ -47,6 +51,10 @@ export const referralRoutes: RouteDefinition[] = [
['GET', '/api/commission/summary', commissionSummaryRoute],
['GET', '/api/commission/orders', commissionOrdersRoute],
['GET', '/api/commission/settlements', commissionSettlementsRoute],
['GET', '/api/commission/settlements/export', commissionSettlementExportRoute],
['GET', '/api/commission/settlements/proofs', commissionSettlementProofsRoute],
['POST', '/api/commission/settlements/generate', generateCommissionSettlementRoute],
['POST', '/api/commission/settlements/status', updateCommissionSettlementStatusRoute],
['POST', '/api/commission/settlements/proofs', createCommissionSettlementProofRoute],
['POST', '/api/commission/settlements/proofs/status', updateCommissionSettlementProofStatusRoute],
];

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,
});
}