forked from wangziqi/gongxue-base
feat: add provider bill download reconciliation jobs
This commit is contained in:
@@ -21,11 +21,13 @@ import {
|
||||
createReconciliationIssueRoute,
|
||||
importReconciliationRoute,
|
||||
previewReconciliationRoute,
|
||||
providerBillDownloadJobsRoute,
|
||||
reconciliationAnomaliesRoute,
|
||||
reconciliationBatchesRoute,
|
||||
reconciliationIssueEventsRoute,
|
||||
reconciliationIssuesRoute,
|
||||
reconciliationItemsRoute,
|
||||
requestProviderBillDownloadRoute,
|
||||
updateReconciliationIssueStatusRoute,
|
||||
} from './reconciliation.js';
|
||||
|
||||
@@ -52,6 +54,8 @@ export const commerceRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute],
|
||||
['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute],
|
||||
['GET', '/api/commerce/reconciliation/anomalies', reconciliationAnomaliesRoute],
|
||||
['POST', '/api/commerce/reconciliation/provider-bills/request', requestProviderBillDownloadRoute],
|
||||
['GET', '/api/commerce/reconciliation/provider-bills/jobs', providerBillDownloadJobsRoute],
|
||||
['POST', '/api/commerce/reconciliation/issues/create', createReconciliationIssueRoute],
|
||||
['GET', '/api/commerce/reconciliation/issues', reconciliationIssuesRoute],
|
||||
['POST', '/api/commerce/reconciliation/issues/status', updateReconciliationIssueStatusRoute],
|
||||
|
||||
@@ -6,9 +6,9 @@ import { intParam, optionalInteger, optionalString, readJsonBody, requiredString
|
||||
import { query, transaction } from '../../core/db.js';
|
||||
import { requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
|
||||
|
||||
type ReconciliationProvider = 'wechat_pay' | 'alipay' | 'manual';
|
||||
type BillType = 'payment' | 'refund' | 'combined';
|
||||
type ReconciliationSource = 'manual_upload' | 'provider_download' | 'api' | 'worker';
|
||||
export type ReconciliationProvider = 'wechat_pay' | 'alipay' | 'manual';
|
||||
export type BillType = 'payment' | 'refund' | 'combined';
|
||||
export type ReconciliationSource = 'manual_upload' | 'provider_download' | 'api' | 'worker';
|
||||
type TransactionType = 'payment' | 'refund';
|
||||
type MatchStatus =
|
||||
| 'matched'
|
||||
@@ -62,6 +62,7 @@ const PROVIDER_SUCCESS_STATUSES = new Set([
|
||||
]);
|
||||
const LOCAL_PAID_PAYMENT_STATUSES = new Set(['paid', 'partially_refunded', 'refunded']);
|
||||
const LOCAL_SUCCEEDED_REFUND_STATUSES = new Set(['succeeded']);
|
||||
const MAX_PROVIDER_DOWNLOAD_ROWS = 50_000;
|
||||
|
||||
interface RawBillRow {
|
||||
[key: string]: unknown;
|
||||
@@ -164,7 +165,19 @@ interface ReconciliationBuildResult {
|
||||
items: ReconciliationItemDraft[];
|
||||
}
|
||||
|
||||
function normalizeProvider(value: string): ReconciliationProvider {
|
||||
export interface ReconciliationBatchImportInput {
|
||||
tenantId: string;
|
||||
actorUserId: string | null;
|
||||
provider: ReconciliationProvider;
|
||||
billDate: string;
|
||||
billType: BillType;
|
||||
source: ReconciliationSource;
|
||||
sourceName: string | null;
|
||||
rows: unknown[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizeReconciliationProvider(value: string): ReconciliationProvider {
|
||||
const normalized = value.trim().toLowerCase().replace(/[-\s]/g, '_');
|
||||
if (['wechat', 'wechatpay', 'wxpay', 'wx_pay', 'wechat_pay'].includes(normalized)) return 'wechat_pay';
|
||||
if (['alipay', 'ali_pay'].includes(normalized)) return 'alipay';
|
||||
@@ -178,7 +191,7 @@ function providerAliases(provider: ReconciliationProvider) {
|
||||
return ['manual'];
|
||||
}
|
||||
|
||||
function normalizeBillType(value: unknown): BillType {
|
||||
export function normalizeReconciliationBillType(value: unknown): BillType {
|
||||
if (typeof value !== 'string' || !value.trim()) return 'combined';
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (BILL_TYPES.has(normalized as BillType)) return normalized as BillType;
|
||||
@@ -192,7 +205,7 @@ function normalizeSource(value: unknown): ReconciliationSource {
|
||||
throw new HttpError(400, 'source is invalid', 'RECONCILIATION_SOURCE_INVALID');
|
||||
}
|
||||
|
||||
function normalizeBillDate(value: string) {
|
||||
export function normalizeReconciliationBillDate(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
throw new HttpError(400, 'billDate must be YYYY-MM-DD', 'RECONCILIATION_BILL_DATE_INVALID');
|
||||
@@ -823,9 +836,9 @@ async function buildReconciliation(
|
||||
}
|
||||
|
||||
function readReconciliationInput(body: Record<string, unknown>) {
|
||||
const provider = normalizeProvider(requiredString(body, 'provider'));
|
||||
const billDate = normalizeBillDate(requiredString(body, 'billDate'));
|
||||
const billType = normalizeBillType(body.billType ?? body.bill_type);
|
||||
const provider = normalizeReconciliationProvider(requiredString(body, 'provider'));
|
||||
const billDate = normalizeReconciliationBillDate(requiredString(body, 'billDate'));
|
||||
const billType = normalizeReconciliationBillType(body.billType ?? body.bill_type);
|
||||
const source = normalizeSource(body.source);
|
||||
const sourceName = optionalString(body, 'sourceName') || optionalString(body, 'source_name') || null;
|
||||
const rawRows = body.rows;
|
||||
@@ -839,6 +852,160 @@ function readReconciliationInput(body: Record<string, unknown>) {
|
||||
return { provider, billDate, billType, source, sourceName, rows };
|
||||
}
|
||||
|
||||
function normalizeProviderDownloadedRows(provider: ReconciliationProvider, billType: BillType, rows: unknown[]) {
|
||||
if (rows.length > MAX_PROVIDER_DOWNLOAD_ROWS) {
|
||||
throw new HttpError(413, `Too many provider bill rows. Max ${MAX_PROVIDER_DOWNLOAD_ROWS}.`, 'RECONCILIATION_PROVIDER_ROWS_TOO_MANY');
|
||||
}
|
||||
return normalizeRows(provider, billType, rows);
|
||||
}
|
||||
|
||||
export async function importReconciliationBatch(
|
||||
client: pg.PoolClient,
|
||||
input: ReconciliationBatchImportInput,
|
||||
) {
|
||||
const provider = input.provider;
|
||||
const billDate = normalizeReconciliationBillDate(input.billDate);
|
||||
const billType = normalizeReconciliationBillType(input.billType);
|
||||
const source = normalizeSource(input.source);
|
||||
const sourceName = input.sourceName;
|
||||
const rows = normalizeProviderDownloadedRows(provider, billType, input.rows);
|
||||
const built = await buildReconciliation(client, {
|
||||
tenantId: input.tenantId,
|
||||
provider,
|
||||
billDate,
|
||||
billType,
|
||||
source,
|
||||
sourceName,
|
||||
rows,
|
||||
});
|
||||
const insertedBatch = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
insert into public.commerce_reconciliation_batches (
|
||||
tenant_id, provider, bill_date, bill_type, source, source_name, source_hash,
|
||||
status, total_count, matched_count, mismatch_count, missing_local_count,
|
||||
missing_provider_count, duplicate_count, ignored_count, amount_cents,
|
||||
refund_amount_cents, fee_cents, created_by, completed_at, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3::date, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16,
|
||||
$17, $18, $19::uuid, now(), $20::jsonb
|
||||
)
|
||||
returning id, provider, bill_date as "billDate", bill_type as "billType",
|
||||
source, source_name as "sourceName", source_hash as "sourceHash",
|
||||
status, total_count as "totalCount", matched_count as "matchedCount",
|
||||
mismatch_count as "mismatchCount", missing_local_count as "missingLocalCount",
|
||||
missing_provider_count as "missingProviderCount", duplicate_count as "duplicateCount",
|
||||
ignored_count as "ignoredCount", amount_cents as "amountCents",
|
||||
refund_amount_cents as "refundAmountCents", fee_cents as "feeCents",
|
||||
created_by as "createdBy", completed_at as "completedAt", error,
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
built.batch.provider,
|
||||
built.batch.billDate,
|
||||
built.batch.billType,
|
||||
built.batch.source,
|
||||
built.batch.sourceName,
|
||||
built.batch.sourceHash,
|
||||
built.batch.status,
|
||||
built.batch.totalCount,
|
||||
built.batch.matchedCount,
|
||||
built.batch.mismatchCount,
|
||||
built.batch.missingLocalCount,
|
||||
built.batch.missingProviderCount,
|
||||
built.batch.duplicateCount,
|
||||
built.batch.ignoredCount,
|
||||
built.batch.amountCents,
|
||||
built.batch.refundAmountCents,
|
||||
built.batch.feeCents,
|
||||
input.actorUserId,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
const batch = insertedBatch.rows[0];
|
||||
const batchId = String(batch.id);
|
||||
|
||||
for (const item of built.items) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.commerce_reconciliation_items (
|
||||
tenant_id, batch_id, row_no, provider, transaction_type,
|
||||
provider_trade_no, provider_refund_no, order_no, refund_no,
|
||||
amount_cents, refund_amount_cents, fee_cents, paid_at, refunded_at,
|
||||
provider_status, local_status, order_id, payment_id, refund_request_id,
|
||||
match_status, severity, issue_code, details
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
$10, $11, $12, $13::timestamptz, $14::timestamptz,
|
||||
$15, $16, $17::uuid, $18::uuid, $19::uuid,
|
||||
$20, $21, $22, $23::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
batchId,
|
||||
item.rowNo,
|
||||
item.provider,
|
||||
item.transactionType,
|
||||
item.providerTradeNo,
|
||||
item.providerRefundNo,
|
||||
item.orderNo,
|
||||
item.refundNo,
|
||||
item.amountCents,
|
||||
item.refundAmountCents,
|
||||
item.feeCents,
|
||||
item.paidAt,
|
||||
item.refundedAt,
|
||||
item.providerStatus,
|
||||
item.localStatus,
|
||||
item.orderId,
|
||||
item.paymentId,
|
||||
item.refundRequestId,
|
||||
item.matchStatus,
|
||||
item.severity,
|
||||
item.issueCode,
|
||||
JSON.stringify(item.details),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2::uuid, 'commerce.reconciliation.imported', 'commerce_reconciliation_batch', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.actorUserId,
|
||||
batchId,
|
||||
JSON.stringify({
|
||||
provider: built.batch.provider,
|
||||
billDate: built.batch.billDate,
|
||||
billType: built.batch.billType,
|
||||
source: built.batch.source,
|
||||
sourceName: built.batch.sourceName,
|
||||
sourceHash: built.batch.sourceHash,
|
||||
summary: {
|
||||
totalCount: built.batch.totalCount,
|
||||
matchedCount: built.batch.matchedCount,
|
||||
mismatchCount: built.batch.mismatchCount,
|
||||
missingLocalCount: built.batch.missingLocalCount,
|
||||
missingProviderCount: built.batch.missingProviderCount,
|
||||
duplicateCount: built.batch.duplicateCount,
|
||||
ignoredCount: built.batch.ignoredCount,
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return { batch, built };
|
||||
}
|
||||
|
||||
function batchPayload(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -1065,6 +1232,158 @@ async function authorizeWrite(ctx: RequestContext) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
async function authorizeDownload(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'tenant:reconciliation:download');
|
||||
return auth;
|
||||
}
|
||||
|
||||
function providerBillJobPayload(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
billDate: row.billDate,
|
||||
billType: row.billType,
|
||||
status: row.status,
|
||||
sourceName: row.sourceName,
|
||||
sourceHash: row.sourceHash,
|
||||
rowCount: row.rowCount,
|
||||
downloadHashType: row.downloadHashType,
|
||||
downloadHashValue: row.downloadHashValue,
|
||||
downloadUrlHost: row.downloadUrlHost,
|
||||
reconciliationBatchId: row.reconciliationBatchId,
|
||||
requestedBy: row.requestedBy,
|
||||
claimedBy: row.claimedBy,
|
||||
claimedAt: row.claimedAt,
|
||||
completedAt: row.completedAt,
|
||||
failedAt: row.failedAt,
|
||||
errorCode: row.errorCode,
|
||||
errorMessage: row.errorMessage,
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestProviderBillDownloadRoute(ctx: RequestContext) {
|
||||
const auth = await authorizeDownload(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const provider = normalizeReconciliationProvider(requiredString(body, 'provider'));
|
||||
if (provider === 'manual') {
|
||||
throw new HttpError(400, 'Manual provider does not support official bill download', 'PROVIDER_BILL_DOWNLOAD_UNSUPPORTED');
|
||||
}
|
||||
const billDate = normalizeReconciliationBillDate(requiredString(body, 'billDate'));
|
||||
const billType = normalizeReconciliationBillType(body.billType ?? body.bill_type);
|
||||
const metadata = objectValue(body.metadata);
|
||||
const sourceName = `provider-bill:${provider}:${billDate}:${billType}`;
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const inserted = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
insert into public.commerce_bill_download_jobs (
|
||||
tenant_id, provider, bill_date, bill_type, status,
|
||||
source_name, requested_by, metadata
|
||||
)
|
||||
values ($1, $2, $3::date, $4, 'queued', $5, $6::uuid, $7::jsonb)
|
||||
on conflict (tenant_id, provider, bill_date, bill_type)
|
||||
where status in ('queued', 'running', 'completed')
|
||||
do update set
|
||||
metadata = public.commerce_bill_download_jobs.metadata || excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, provider, bill_date as "billDate", bill_type as "billType",
|
||||
status, source_name as "sourceName", source_hash as "sourceHash",
|
||||
row_count as "rowCount", download_hash_type as "downloadHashType",
|
||||
download_hash_value as "downloadHashValue", download_url_host as "downloadUrlHost",
|
||||
reconciliation_batch_id as "reconciliationBatchId",
|
||||
requested_by as "requestedBy", claimed_by as "claimedBy",
|
||||
claimed_at as "claimedAt", completed_at as "completedAt",
|
||||
failed_at as "failedAt", error_code as "errorCode",
|
||||
error_message as "errorMessage", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt",
|
||||
(xmax = 0) as "inserted"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
provider,
|
||||
billDate,
|
||||
billType,
|
||||
sourceName,
|
||||
auth.userId,
|
||||
JSON.stringify({
|
||||
...metadata,
|
||||
requestedFrom: 'api',
|
||||
}),
|
||||
],
|
||||
);
|
||||
const job = inserted.rows[0];
|
||||
await recordReconciliationAudit(
|
||||
client,
|
||||
auth,
|
||||
job.inserted ? 'commerce.provider_bill_download.requested' : 'commerce.provider_bill_download.request_reused',
|
||||
String(job.id),
|
||||
{
|
||||
provider,
|
||||
billDate,
|
||||
billType,
|
||||
sourceName,
|
||||
status: job.status,
|
||||
},
|
||||
);
|
||||
return job;
|
||||
});
|
||||
|
||||
return {
|
||||
item: providerBillJobPayload(result),
|
||||
idempotent: result.inserted === false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function providerBillDownloadJobsRoute(ctx: RequestContext) {
|
||||
const auth = await authorizeRead(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const provider = stringParam(ctx, 'provider');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const billDate = stringParam(ctx, 'billDate');
|
||||
|
||||
const params: unknown[] = [auth.tenantId, limit];
|
||||
const where = ['tenant_id = $1'];
|
||||
if (provider) {
|
||||
const normalized = normalizeReconciliationProvider(provider);
|
||||
if (normalized === 'manual') throw new HttpError(400, 'provider is invalid for bill download', 'PROVIDER_BILL_DOWNLOAD_UNSUPPORTED');
|
||||
params.push(normalized);
|
||||
where.push(`provider = $${params.length}`);
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
where.push(`status = $${params.length}`);
|
||||
}
|
||||
if (billDate) {
|
||||
params.push(normalizeReconciliationBillDate(billDate));
|
||||
where.push(`bill_date = $${params.length}::date`);
|
||||
}
|
||||
|
||||
const items = await query<Record<string, unknown>>(
|
||||
`
|
||||
select id, provider, bill_date as "billDate", bill_type as "billType",
|
||||
status, source_name as "sourceName", source_hash as "sourceHash",
|
||||
row_count as "rowCount", download_hash_type as "downloadHashType",
|
||||
download_hash_value as "downloadHashValue", download_url_host as "downloadUrlHost",
|
||||
reconciliation_batch_id as "reconciliationBatchId",
|
||||
requested_by as "requestedBy", claimed_by as "claimedBy",
|
||||
claimed_at as "claimedAt", completed_at as "completedAt",
|
||||
failed_at as "failedAt", error_code as "errorCode",
|
||||
error_message as "errorMessage", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.commerce_bill_download_jobs
|
||||
where ${where.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $2
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return { items: items.map(providerBillJobPayload) };
|
||||
}
|
||||
|
||||
export async function previewReconciliationRoute(ctx: RequestContext) {
|
||||
const auth = await authorizeRead(ctx);
|
||||
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
|
||||
@@ -1090,122 +1409,21 @@ export async function importReconciliationRoute(ctx: RequestContext) {
|
||||
const metadata = objectValue(body.metadata);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const built = await buildReconciliation(client, { tenantId: auth.tenantId, ...input });
|
||||
const insertedBatch = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
insert into public.commerce_reconciliation_batches (
|
||||
tenant_id, provider, bill_date, bill_type, source, source_name, source_hash,
|
||||
status, total_count, matched_count, mismatch_count, missing_local_count,
|
||||
missing_provider_count, duplicate_count, ignored_count, amount_cents,
|
||||
refund_amount_cents, fee_cents, created_by, completed_at, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3::date, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16,
|
||||
$17, $18, $19, now(), $20::jsonb
|
||||
)
|
||||
returning id, provider, bill_date as "billDate", bill_type as "billType",
|
||||
source, source_name as "sourceName", source_hash as "sourceHash",
|
||||
status, total_count as "totalCount", matched_count as "matchedCount",
|
||||
mismatch_count as "mismatchCount", missing_local_count as "missingLocalCount",
|
||||
missing_provider_count as "missingProviderCount", duplicate_count as "duplicateCount",
|
||||
ignored_count as "ignoredCount", amount_cents as "amountCents",
|
||||
refund_amount_cents as "refundAmountCents", fee_cents as "feeCents",
|
||||
created_by as "createdBy", completed_at as "completedAt", error,
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
built.batch.provider,
|
||||
built.batch.billDate,
|
||||
built.batch.billType,
|
||||
built.batch.source,
|
||||
built.batch.sourceName,
|
||||
built.batch.sourceHash,
|
||||
built.batch.status,
|
||||
built.batch.totalCount,
|
||||
built.batch.matchedCount,
|
||||
built.batch.mismatchCount,
|
||||
built.batch.missingLocalCount,
|
||||
built.batch.missingProviderCount,
|
||||
built.batch.duplicateCount,
|
||||
built.batch.ignoredCount,
|
||||
built.batch.amountCents,
|
||||
built.batch.refundAmountCents,
|
||||
built.batch.feeCents,
|
||||
auth.userId,
|
||||
JSON.stringify(metadata),
|
||||
],
|
||||
);
|
||||
const batch = insertedBatch.rows[0];
|
||||
const batchId = String(batch.id);
|
||||
|
||||
for (const item of built.items) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.commerce_reconciliation_items (
|
||||
tenant_id, batch_id, row_no, provider, transaction_type,
|
||||
provider_trade_no, provider_refund_no, order_no, refund_no,
|
||||
amount_cents, refund_amount_cents, fee_cents, paid_at, refunded_at,
|
||||
provider_status, local_status, order_id, payment_id, refund_request_id,
|
||||
match_status, severity, issue_code, details
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
$10, $11, $12, $13::timestamptz, $14::timestamptz,
|
||||
$15, $16, $17::uuid, $18::uuid, $19::uuid,
|
||||
$20, $21, $22, $23::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
batchId,
|
||||
item.rowNo,
|
||||
item.provider,
|
||||
item.transactionType,
|
||||
item.providerTradeNo,
|
||||
item.providerRefundNo,
|
||||
item.orderNo,
|
||||
item.refundNo,
|
||||
item.amountCents,
|
||||
item.refundAmountCents,
|
||||
item.feeCents,
|
||||
item.paidAt,
|
||||
item.refundedAt,
|
||||
item.providerStatus,
|
||||
item.localStatus,
|
||||
item.orderId,
|
||||
item.paymentId,
|
||||
item.refundRequestId,
|
||||
item.matchStatus,
|
||||
item.severity,
|
||||
item.issueCode,
|
||||
JSON.stringify(item.details),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await recordReconciliationAudit(client, auth, 'commerce.reconciliation.imported', batchId, {
|
||||
provider: built.batch.provider,
|
||||
billDate: built.batch.billDate,
|
||||
billType: built.batch.billType,
|
||||
source: built.batch.source,
|
||||
sourceName: built.batch.sourceName,
|
||||
sourceHash: built.batch.sourceHash,
|
||||
summary: {
|
||||
totalCount: built.batch.totalCount,
|
||||
matchedCount: built.batch.matchedCount,
|
||||
mismatchCount: built.batch.mismatchCount,
|
||||
missingLocalCount: built.batch.missingLocalCount,
|
||||
missingProviderCount: built.batch.missingProviderCount,
|
||||
duplicateCount: built.batch.duplicateCount,
|
||||
ignoredCount: built.batch.ignoredCount,
|
||||
},
|
||||
const imported = await importReconciliationBatch(client, {
|
||||
tenantId: auth.tenantId,
|
||||
actorUserId: auth.userId,
|
||||
provider: input.provider,
|
||||
billDate: input.billDate,
|
||||
billType: input.billType,
|
||||
source: input.source,
|
||||
sourceName: input.sourceName,
|
||||
rows: body.rows as unknown[],
|
||||
metadata,
|
||||
});
|
||||
|
||||
return { batch, previewItems: built.items.slice(0, optionalInteger(body, 'previewLimit', 50)) };
|
||||
return {
|
||||
batch: imported.batch,
|
||||
previewItems: imported.built.items.slice(0, optionalInteger(body, 'previewLimit', 50)),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1224,7 +1442,7 @@ export async function reconciliationBatchesRoute(ctx: RequestContext) {
|
||||
const params: unknown[] = [auth.tenantId, limit];
|
||||
const where = ['tenant_id = $1'];
|
||||
if (provider) {
|
||||
params.push(normalizeProvider(provider));
|
||||
params.push(normalizeReconciliationProvider(provider));
|
||||
where.push(`provider = $${params.length}`);
|
||||
}
|
||||
if (status) {
|
||||
@@ -1232,7 +1450,7 @@ export async function reconciliationBatchesRoute(ctx: RequestContext) {
|
||||
where.push(`status = $${params.length}`);
|
||||
}
|
||||
if (billDate) {
|
||||
params.push(normalizeBillDate(billDate));
|
||||
params.push(normalizeReconciliationBillDate(billDate));
|
||||
where.push(`bill_date = $${params.length}::date`);
|
||||
}
|
||||
|
||||
@@ -1309,7 +1527,7 @@ export async function reconciliationAnomaliesRoute(ctx: RequestContext) {
|
||||
const auth = await authorizeRead(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const providerParam = stringParam(ctx, 'provider');
|
||||
const provider = providerParam ? normalizeProvider(providerParam) : '';
|
||||
const provider = providerParam ? normalizeReconciliationProvider(providerParam) : '';
|
||||
|
||||
const issueParams: unknown[] = [auth.tenantId, limit];
|
||||
const issueWhere = [`i.tenant_id = $1`, `i.match_status not in ('matched', 'ignored')`];
|
||||
|
||||
@@ -92,6 +92,7 @@ export function tenantPermissionCatalog() {
|
||||
{ key: 'tenant:payment:write', label: '商户配置管理' },
|
||||
{ key: 'tenant:reconciliation:read', label: '资金对账查看' },
|
||||
{ key: 'tenant:reconciliation:write', label: '资金对账导入' },
|
||||
{ key: 'tenant:reconciliation:download', label: '官方账单下载' },
|
||||
{ key: 'tenant:refund:read', label: '退款查看' },
|
||||
{ key: 'tenant:refund:write', label: '退款申请/处理' },
|
||||
{ key: 'tenant:refund:review', label: '退款审核' },
|
||||
|
||||
Reference in New Issue
Block a user