forked from wangziqi/gongxue-base
feat: add commission settlement exports and proofs
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user