feat: add reconciliation issue workflow

This commit is contained in:
Codex
2026-06-29 19:05:18 +08:00
parent 7258e4a7d5
commit d696fc38b0
14 changed files with 924 additions and 26 deletions

View File

@@ -22,14 +22,14 @@
- `apps/worker` 后台任务进程CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态assets worker 可复检托管资源元数据并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
- 资金对账基础闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志。
- 资金对账和差错工单闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕。工单只做财务审核闭环,不直接修改订单、支付、退款或权益
- PocketBase schema/数据导入器雏形和导入后校验脚本。
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
还没有达到生产交付的部分:
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对,微信/支付宝官方账单自动下载、差错处理工单和真实生产账号联调还没接完。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对和差错工单处理,微信/支付宝官方账单自动下载、异常订单运营台和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
@@ -278,7 +278,7 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失
apps/api/src/features/
auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账
commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账和差错工单
health/ 健康检查
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
@@ -309,7 +309,7 @@ API 身份上下文:
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时顶层题目和复合题子题都必须脱敏PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定期通过资金对账接口导入供应商账单核对本地订单。
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定期通过资金对账接口导入供应商账单核对本地订单。对账差错工单只允许记录财务处理结论和凭证,不允许前端或工单接口直接篡改订单、支付、退款或权益状态。
## 最近一次验证
@@ -338,4 +338,4 @@ git diff --check
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI平台后台租户详情/审计/自动计费增强,小程序兼容验证。
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载与差错处理台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载、异常订单运营台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -18,11 +18,15 @@ import {
updateRefundStatusRoute,
} from './routes.js';
import {
createReconciliationIssueRoute,
importReconciliationRoute,
previewReconciliationRoute,
reconciliationAnomaliesRoute,
reconciliationBatchesRoute,
reconciliationIssueEventsRoute,
reconciliationIssuesRoute,
reconciliationItemsRoute,
updateReconciliationIssueStatusRoute,
} from './reconciliation.js';
export const commerceRoutes: RouteDefinition[] = [
@@ -48,6 +52,10 @@ 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/issues/create', createReconciliationIssueRoute],
['GET', '/api/commerce/reconciliation/issues', reconciliationIssuesRoute],
['POST', '/api/commerce/reconciliation/issues/status', updateReconciliationIssueStatusRoute],
['GET', '/api/commerce/reconciliation/issues/events', reconciliationIssueEventsRoute],
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
['POST', '/api/commerce/coupons/claim', claimCouponRoute],

View File

@@ -19,9 +19,37 @@ type MatchStatus =
| 'duplicate'
| 'ignored';
type Severity = 'info' | 'warning' | 'error' | 'critical';
type ReconciliationIssueStatus = 'open' | 'investigating' | 'resolved' | 'ignored' | 'escalated';
type ReconciliationResolutionType =
| 'none'
| 'provider_confirmed'
| 'local_corrected'
| 'manual_adjustment'
| 'false_positive'
| 'duplicate'
| 'write_off';
type ReconciliationIssueAction = 'start' | 'assign' | 'resolve' | 'ignore' | 'escalate' | 'reopen';
const BILL_TYPES = new Set<BillType>(['payment', 'refund', 'combined']);
const SOURCES = new Set<ReconciliationSource>(['manual_upload', 'provider_download', 'api', 'worker']);
const ACTIONABLE_MATCH_STATUSES = new Set<MatchStatus>([
'amount_mismatch',
'status_mismatch',
'missing_local',
'missing_provider',
'duplicate',
]);
const ISSUE_STATUSES = new Set<ReconciliationIssueStatus>(['open', 'investigating', 'resolved', 'ignored', 'escalated']);
const RESOLUTION_TYPES = new Set<ReconciliationResolutionType>([
'none',
'provider_confirmed',
'local_corrected',
'manual_adjustment',
'false_positive',
'duplicate',
'write_off',
]);
const ISSUE_ACTIONS = new Set<ReconciliationIssueAction>(['start', 'assign', 'resolve', 'ignore', 'escalate', 'reopen']);
const PROVIDER_SUCCESS_STATUSES = new Set([
'success',
'succeeded',
@@ -180,6 +208,32 @@ function objectValue(value: unknown): RawBillRow {
return value && typeof value === 'object' && !Array.isArray(value) ? value as RawBillRow : {};
}
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function jsonBodyValue(value: unknown) {
return JSON.stringify(objectValue(value));
}
function optionalChoice<T extends string>(value: unknown, allowed: Set<T>, fallback: T, code = 'INVALID_FIELD_VALUE') {
const candidate = (nullableString(value) || fallback) as T;
if (!allowed.has(candidate)) {
throw new HttpError(400, `Invalid value: ${candidate}`, code);
}
return candidate;
}
function timestampValue(value: unknown, fieldName: string) {
const raw = nullableString(value);
if (!raw) return null;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) {
throw new HttpError(400, `${fieldName} must be a valid timestamp`, 'INVALID_TIMESTAMP');
}
return date.toISOString();
}
function optionalRowString(row: RawBillRow, keys: string[]) {
for (const key of keys) {
const value = row[key];
@@ -843,6 +897,59 @@ function itemPayload(row: Record<string, unknown>) {
};
}
function issuePayload(row: Record<string, unknown>) {
return {
id: row.id,
issueNo: row.issueNo,
batchId: row.batchId,
itemId: row.itemId,
provider: row.provider,
transactionType: row.transactionType,
issueCode: row.issueCode,
matchStatus: row.matchStatus,
severity: row.severity,
status: row.status,
resolutionType: row.resolutionType,
orderId: row.orderId,
paymentId: row.paymentId,
refundRequestId: row.refundRequestId,
orderNo: row.orderNo,
refundNo: row.refundNo,
providerTradeNo: row.providerTradeNo,
providerRefundNo: row.providerRefundNo,
amountCents: row.amountCents,
refundAmountCents: row.refundAmountCents,
assignedTo: row.assignedTo,
assignedToName: row.assignedToName,
createdBy: row.createdBy,
createdByName: row.createdByName,
resolvedBy: row.resolvedBy,
resolvedByName: row.resolvedByName,
resolvedAt: row.resolvedAt,
dueAt: row.dueAt,
summary: row.summary,
resolutionNote: row.resolutionNote,
metadata: row.metadata || {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function issueEventPayload(row: Record<string, unknown>) {
return {
id: row.id,
issueId: row.issueId,
fromStatus: row.fromStatus,
toStatus: row.toStatus,
eventType: row.eventType,
actorUserId: row.actorUserId,
actorName: row.actorName,
note: row.note,
details: row.details || {},
createdAt: row.createdAt,
};
}
async function recordReconciliationAudit(client: pg.PoolClient, auth: TenantAdminAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
await client.query(
`
@@ -853,6 +960,99 @@ async function recordReconciliationAudit(client: pg.PoolClient, auth: TenantAdmi
);
}
async function recordReconciliationIssueEvent(
client: pg.PoolClient,
auth: TenantAdminAuth,
input: {
issueId: string;
fromStatus?: string | null;
toStatus?: string | null;
eventType: string;
note?: string | null;
details?: Record<string, unknown>;
},
) {
await client.query(
`
insert into public.commerce_reconciliation_issue_events (
tenant_id, issue_id, from_status, to_status, event_type, actor_user_id, note, details
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
auth.tenantId,
input.issueId,
input.fromStatus || null,
input.toStatus || null,
input.eventType,
auth.userId,
input.note || null,
JSON.stringify(input.details || {}),
],
);
}
async function ensureAssignableTenantMember(client: pg.PoolClient, tenantId: string, userId: string | null) {
if (!userId) return null;
const result = await client.query<{ id: string }>(
`
select tm.user_id as id
from public.tenant_memberships tm
where tm.tenant_id = $1
and tm.user_id = $2
and tm.status = 'active'
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
limit 1
`,
[tenantId, userId],
);
if (!result.rows[0]) {
throw new HttpError(400, 'Assignee must be an active tenant staff member', 'RECONCILIATION_ASSIGNEE_INVALID');
}
return userId;
}
function nextIssueNo() {
const dateKey = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `RC-${dateKey}-${crypto.randomInt(100000, 999999)}`;
}
function issueSummaryForItem(item: Record<string, unknown>, summary: string | null) {
if (summary) return summary;
const identity = item.orderNo || item.refundNo || item.providerTradeNo || item.providerRefundNo || item.id;
return `${item.issueCode || item.matchStatus || 'RECONCILIATION_ISSUE'} ${identity}`;
}
async function fetchIssueForTenant(client: pg.PoolClient, tenantId: string, issueId: string, lock = false) {
const result = await client.query<Record<string, unknown>>(
`
select i.id, i.issue_no as "issueNo", i.batch_id as "batchId", i.item_id as "itemId",
i.provider, i.transaction_type as "transactionType", i.issue_code as "issueCode",
i.match_status as "matchStatus", i.severity, i.status,
i.resolution_type as "resolutionType", i.order_id as "orderId",
i.payment_id as "paymentId", i.refund_request_id as "refundRequestId",
i.order_no as "orderNo", i.refund_no as "refundNo",
i.provider_trade_no as "providerTradeNo", i.provider_refund_no as "providerRefundNo",
i.amount_cents as "amountCents", i.refund_amount_cents as "refundAmountCents",
i.assigned_to as "assignedTo", assignee.name as "assignedToName",
i.created_by as "createdBy", creator.name as "createdByName",
i.resolved_by as "resolvedBy", resolver.name as "resolvedByName",
i.resolved_at as "resolvedAt", i.due_at as "dueAt", i.summary,
i.resolution_note as "resolutionNote", i.metadata,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.commerce_reconciliation_issues i
left join public.platform_users assignee on assignee.id = i.assigned_to
left join public.platform_users creator on creator.id = i.created_by
left join public.platform_users resolver on resolver.id = i.resolved_by
where i.tenant_id = $1 and i.id = $2
limit 1
${lock ? 'for update of i' : ''}
`,
[tenantId, issueId],
);
return result.rows[0] || null;
}
async function authorizeRead(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:reconciliation:read');
@@ -1163,3 +1363,394 @@ export async function reconciliationAnomaliesRoute(ctx: RequestContext) {
paymentEventErrors,
};
}
export async function createReconciliationIssueRoute(ctx: RequestContext) {
const auth = await authorizeWrite(ctx);
const body = await readJsonBody(ctx);
const itemId = requiredString(body, 'itemId');
const assignedTo = nullableString(body.assignedTo) || nullableString(body.assignedToUserId);
const dueAt = timestampValue(body.dueAt, 'dueAt');
const summary = nullableString(body.summary);
const note = nullableString(body.note);
const result = await transaction(async client => {
const itemResult = await client.query<Record<string, unknown>>(
`
select id, batch_id as "batchId", provider, transaction_type as "transactionType",
provider_trade_no as "providerTradeNo", provider_refund_no as "providerRefundNo",
order_no as "orderNo", refund_no as "refundNo",
amount_cents as "amountCents", refund_amount_cents as "refundAmountCents",
order_id as "orderId", payment_id as "paymentId", refund_request_id as "refundRequestId",
match_status as "matchStatus", severity, issue_code as "issueCode", details
from public.commerce_reconciliation_items
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, itemId],
);
const item = itemResult.rows[0];
if (!item) throw new HttpError(404, 'Reconciliation item not found', 'RECONCILIATION_ITEM_NOT_FOUND');
if (!ACTIONABLE_MATCH_STATUSES.has(item.matchStatus as MatchStatus)) {
throw new HttpError(400, 'Reconciliation item is not actionable', 'RECONCILIATION_ITEM_NOT_ACTIONABLE');
}
const existing = await client.query<Record<string, unknown>>(
`
select i.id, i.issue_no as "issueNo", i.batch_id as "batchId", i.item_id as "itemId",
i.provider, i.transaction_type as "transactionType", i.issue_code as "issueCode",
i.match_status as "matchStatus", i.severity, i.status,
i.resolution_type as "resolutionType", i.order_id as "orderId",
i.payment_id as "paymentId", i.refund_request_id as "refundRequestId",
i.order_no as "orderNo", i.refund_no as "refundNo",
i.provider_trade_no as "providerTradeNo", i.provider_refund_no as "providerRefundNo",
i.amount_cents as "amountCents", i.refund_amount_cents as "refundAmountCents",
i.assigned_to as "assignedTo", assignee.name as "assignedToName",
i.created_by as "createdBy", creator.name as "createdByName",
i.resolved_by as "resolvedBy", resolver.name as "resolvedByName",
i.resolved_at as "resolvedAt", i.due_at as "dueAt", i.summary,
i.resolution_note as "resolutionNote", i.metadata,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.commerce_reconciliation_issues i
left join public.platform_users assignee on assignee.id = i.assigned_to
left join public.platform_users creator on creator.id = i.created_by
left join public.platform_users resolver on resolver.id = i.resolved_by
where i.tenant_id = $1
and i.item_id = $2
and i.status in ('open', 'investigating', 'escalated')
order by i.created_at desc
limit 1
`,
[auth.tenantId, itemId],
);
if (existing.rows[0]) return { issue: existing.rows[0], idempotent: true };
const assignee = await ensureAssignableTenantMember(client, auth.tenantId, assignedTo);
const issue = await client.query<Record<string, unknown>>(
`
insert into public.commerce_reconciliation_issues (
tenant_id, batch_id, item_id, issue_no, provider, transaction_type,
issue_code, match_status, severity, status, resolution_type,
order_id, payment_id, refund_request_id, order_no, refund_no,
provider_trade_no, provider_refund_no, amount_cents, refund_amount_cents,
assigned_to, created_by, due_at, summary, metadata
)
values (
$1, $2::uuid, $3::uuid, $4, $5, $6,
$7, $8, $9, 'open', 'none',
$10::uuid, $11::uuid, $12::uuid, $13, $14,
$15, $16, $17, $18,
$19::uuid, $20::uuid, $21::timestamptz, $22, $23::jsonb
)
returning id, issue_no as "issueNo", batch_id as "batchId", item_id as "itemId",
provider, transaction_type as "transactionType", issue_code as "issueCode",
match_status as "matchStatus", severity, status,
resolution_type as "resolutionType", order_id as "orderId",
payment_id as "paymentId", refund_request_id as "refundRequestId",
order_no as "orderNo", refund_no as "refundNo",
provider_trade_no as "providerTradeNo", provider_refund_no as "providerRefundNo",
amount_cents as "amountCents", refund_amount_cents as "refundAmountCents",
assigned_to as "assignedTo", created_by as "createdBy", resolved_by as "resolvedBy",
resolved_at as "resolvedAt", due_at as "dueAt", summary,
resolution_note as "resolutionNote", metadata, created_at as "createdAt",
updated_at as "updatedAt"
`,
[
auth.tenantId,
item.batchId,
item.id,
nextIssueNo(),
item.provider,
item.transactionType,
item.issueCode,
item.matchStatus,
item.severity,
item.orderId,
item.paymentId,
item.refundRequestId,
item.orderNo,
item.refundNo,
item.providerTradeNo,
item.providerRefundNo,
item.amountCents,
item.refundAmountCents,
assignee,
auth.userId,
dueAt,
issueSummaryForItem(item, summary),
jsonBodyValue(body.metadata),
],
);
const created = await fetchIssueForTenant(client, auth.tenantId, String(issue.rows[0].id));
await recordReconciliationIssueEvent(client, auth, {
issueId: String(issue.rows[0].id),
toStatus: 'open',
eventType: 'created',
note,
details: {
itemId,
assignedTo: assignee,
dueAt,
matchStatus: item.matchStatus,
issueCode: item.issueCode,
},
});
await recordReconciliationAudit(client, auth, 'commerce.reconciliation_issue.created', String(issue.rows[0].id), {
itemId,
batchId: item.batchId,
assignedTo: assignee,
dueAt,
matchStatus: item.matchStatus,
issueCode: item.issueCode,
});
return { issue: created || issue.rows[0], idempotent: false };
});
return { item: issuePayload(result.issue), idempotent: result.idempotent };
}
export async function reconciliationIssuesRoute(ctx: RequestContext) {
const auth = await authorizeRead(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const status = stringParam(ctx, 'status');
const severity = stringParam(ctx, 'severity');
const assignedTo = stringParam(ctx, 'assignedTo') || stringParam(ctx, 'assignedToUserId');
const batchId = stringParam(ctx, 'batchId');
const orderNo = stringParam(ctx, 'orderNo');
const params: unknown[] = [auth.tenantId, limit];
const where = ['i.tenant_id = $1'];
if (status) {
if (!ISSUE_STATUSES.has(status as ReconciliationIssueStatus)) {
throw new HttpError(400, 'status is invalid', 'RECONCILIATION_ISSUE_STATUS_INVALID');
}
params.push(status);
where.push(`i.status = $${params.length}`);
}
if (severity) {
params.push(severity);
where.push(`i.severity = $${params.length}`);
}
if (assignedTo) {
params.push(assignedTo);
where.push(`i.assigned_to = $${params.length}::uuid`);
}
if (batchId) {
params.push(batchId);
where.push(`i.batch_id = $${params.length}::uuid`);
}
if (orderNo) {
params.push(orderNo);
where.push(`i.order_no = $${params.length}`);
}
const items = await query<Record<string, unknown>>(
`
select i.id, i.issue_no as "issueNo", i.batch_id as "batchId", i.item_id as "itemId",
i.provider, i.transaction_type as "transactionType", i.issue_code as "issueCode",
i.match_status as "matchStatus", i.severity, i.status,
i.resolution_type as "resolutionType", i.order_id as "orderId",
i.payment_id as "paymentId", i.refund_request_id as "refundRequestId",
i.order_no as "orderNo", i.refund_no as "refundNo",
i.provider_trade_no as "providerTradeNo", i.provider_refund_no as "providerRefundNo",
i.amount_cents as "amountCents", i.refund_amount_cents as "refundAmountCents",
i.assigned_to as "assignedTo", assignee.name as "assignedToName",
i.created_by as "createdBy", creator.name as "createdByName",
i.resolved_by as "resolvedBy", resolver.name as "resolvedByName",
i.resolved_at as "resolvedAt", i.due_at as "dueAt", i.summary,
i.resolution_note as "resolutionNote", i.metadata,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.commerce_reconciliation_issues i
left join public.platform_users assignee on assignee.id = i.assigned_to
left join public.platform_users creator on creator.id = i.created_by
left join public.platform_users resolver on resolver.id = i.resolved_by
where ${where.join(' and ')}
order by
case i.status
when 'open' then 1
when 'investigating' then 2
when 'escalated' then 3
when 'resolved' then 4
else 5
end,
i.severity desc,
i.created_at desc
limit $2
`,
params,
);
return { items: items.map(issuePayload) };
}
export async function updateReconciliationIssueStatusRoute(ctx: RequestContext) {
const auth = await authorizeWrite(ctx);
const body = await readJsonBody(ctx);
const issueId = requiredString(body, 'issueId');
const action = optionalChoice(body.action, ISSUE_ACTIONS, 'start', 'RECONCILIATION_ISSUE_ACTION_INVALID');
const note = nullableString(body.note);
const dueAt = timestampValue(body.dueAt, 'dueAt');
const assignedTo = nullableString(body.assignedTo) || nullableString(body.assignedToUserId);
const resolutionTypeInput = nullableString(body.resolutionType);
const result = await transaction(async client => {
const issue = await fetchIssueForTenant(client, auth.tenantId, issueId, true);
if (!issue) throw new HttpError(404, 'Reconciliation issue not found', 'RECONCILIATION_ISSUE_NOT_FOUND');
const currentStatus = String(issue.status) as ReconciliationIssueStatus;
let nextStatus = currentStatus;
let nextResolutionType = String(issue.resolutionType || 'none') as ReconciliationResolutionType;
let resolvedBy: string | null = String(issue.resolvedBy || '') || null;
let resolvedAt: string | null = String(issue.resolvedAt || '') || null;
let resolutionNote = nullableString(body.resolutionNote) || nullableString(body.note) || String(issue.resolutionNote || '') || null;
const assignee = assignedTo ? await ensureAssignableTenantMember(client, auth.tenantId, assignedTo) : null;
const details: Record<string, unknown> = {
action,
previousStatus: currentStatus,
};
if (action === 'start') {
if (!['open', 'escalated'].includes(currentStatus)) {
throw new HttpError(409, 'Only open or escalated issues can be started', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextStatus = 'investigating';
} else if (action === 'assign') {
if (!assignee) {
throw new HttpError(400, 'assignedTo is required for assign action', 'RECONCILIATION_ASSIGNEE_REQUIRED');
}
details.assignedTo = assignee;
} else if (action === 'resolve') {
if (['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Closed reconciliation issues must be reopened first', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextResolutionType = optionalChoice(
resolutionTypeInput,
RESOLUTION_TYPES,
'manual_adjustment',
'RECONCILIATION_RESOLUTION_TYPE_INVALID',
);
if (nextResolutionType === 'none') {
throw new HttpError(400, 'resolutionType is required when resolving an issue', 'RECONCILIATION_RESOLUTION_REQUIRED');
}
nextStatus = 'resolved';
resolvedBy = auth.userId;
resolvedAt = new Date().toISOString();
details.resolutionType = nextResolutionType;
} else if (action === 'ignore') {
if (['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Closed reconciliation issues must be reopened first', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextResolutionType = optionalChoice(
resolutionTypeInput,
RESOLUTION_TYPES,
'false_positive',
'RECONCILIATION_RESOLUTION_TYPE_INVALID',
);
if (nextResolutionType === 'none') nextResolutionType = 'false_positive';
nextStatus = 'ignored';
resolvedBy = auth.userId;
resolvedAt = new Date().toISOString();
details.resolutionType = nextResolutionType;
} else if (action === 'escalate') {
if (['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Closed reconciliation issues must be reopened first', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextStatus = 'escalated';
} else if (action === 'reopen') {
if (!['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Only closed reconciliation issues can be reopened', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextStatus = 'open';
nextResolutionType = 'none';
resolvedBy = null;
resolvedAt = null;
resolutionNote = null;
}
const update = await client.query<Record<string, unknown>>(
`
update public.commerce_reconciliation_issues
set status = $3,
resolution_type = $4,
resolution_note = $5,
assigned_to = coalesce($6::uuid, assigned_to),
due_at = coalesce($7::timestamptz, due_at),
resolved_by = $8::uuid,
resolved_at = $9::timestamptz,
metadata = metadata || $10::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
returning id
`,
[
auth.tenantId,
issueId,
nextStatus,
nextResolutionType,
resolutionNote,
assignee,
dueAt,
resolvedBy,
resolvedAt,
jsonBodyValue(body.metadata),
],
);
if (!update.rows[0]) throw new HttpError(404, 'Reconciliation issue not found', 'RECONCILIATION_ISSUE_NOT_FOUND');
await recordReconciliationIssueEvent(client, auth, {
issueId,
fromStatus: currentStatus,
toStatus: nextStatus,
eventType: action,
note,
details: {
...details,
assignedTo: assignee || issue.assignedTo || null,
dueAt: dueAt || issue.dueAt || null,
},
});
await recordReconciliationAudit(client, auth, 'commerce.reconciliation_issue.status_updated', issueId, {
action,
fromStatus: currentStatus,
toStatus: nextStatus,
assignedTo: assignee || issue.assignedTo || null,
dueAt: dueAt || issue.dueAt || null,
resolutionType: nextResolutionType,
noteProvided: Boolean(note),
});
const nextIssue = await fetchIssueForTenant(client, auth.tenantId, issueId);
return nextIssue || issue;
});
return { item: issuePayload(result) };
}
export async function reconciliationIssueEventsRoute(ctx: RequestContext) {
const auth = await authorizeRead(ctx);
const issueId = stringParam(ctx, 'issueId');
if (!issueId) throw new HttpError(400, 'issueId is required', 'RECONCILIATION_ISSUE_ID_REQUIRED');
const limit = intParam(ctx, 'limit', 100, 300);
const issue = await query<Record<string, unknown>>(
'select id from public.commerce_reconciliation_issues where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, issueId],
);
if (!issue[0]) throw new HttpError(404, 'Reconciliation issue not found', 'RECONCILIATION_ISSUE_NOT_FOUND');
const events = await query<Record<string, unknown>>(
`
select e.id, e.issue_id as "issueId", e.from_status as "fromStatus",
e.to_status as "toStatus", e.event_type as "eventType",
e.actor_user_id as "actorUserId", actor.name as "actorName",
e.note, e.details, e.created_at as "createdAt"
from public.commerce_reconciliation_issue_events e
left join public.platform_users actor on actor.id = e.actor_user_id
where e.tenant_id = $1 and e.issue_id = $2
order by e.created_at asc
limit $3
`,
[auth.tenantId, issueId, limit],
);
return { items: events.map(issueEventPayload) };
}

View File

@@ -281,13 +281,17 @@ body: { "code": "<qq oauth code>", "redirectUri": "https://h5.example.com/auth/q
- `GET /api/commerce/reconciliation/batches`:查询对账批次。
- `GET /api/commerce/reconciliation/items`:查询逐行结果。
- `GET /api/commerce/reconciliation/anomalies`:查询金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行等异常。
- `POST /api/commerce/reconciliation/issues/create`:从异常明细创建差错工单,重复创建同一未关闭明细会返回现有工单。
- `GET /api/commerce/reconciliation/issues`:按状态、严重级别、负责人、批次、订单号筛选差错工单。
- `POST /api/commerce/reconciliation/issues/status`:执行 `start/assign/resolve/ignore/escalate/reopen` 状态流转。
- `GET /api/commerce/reconciliation/issues/events`:查看工单创建、分配、处理、解决、忽略、重开等事件轨迹。
权限点:
- `tenant:reconciliation:read`:查看/预览对账。
- `tenant:reconciliation:write`:导入对账批次。
- `tenant:reconciliation:write`:导入对账批次、创建/处理差错工单
对账只生成差异台账,不自动修改订单、支付、退款和权益。真实生产中,微信/支付宝官方账单下载 adapter 应复用同一套 `commerce_reconciliation_batches/items`,下载后的 CSV/JSON 先规范化为 `rows`,再调用同一套匹配逻辑。差异处理、人工调整凭证、财务复核和异常订单运营台仍需要继续补。
对账和差错工单只生成差异台账、处理记录和审计,不自动修改订单、支付、退款和权益。`resolve/ignore` 只是财务审核结论,例如 `manual_adjustment``provider_confirmed``false_positive`,最终落账仍必须走退款状态机、支付补偿、手工支付确认或后续专门的人工调整命令。真实生产中,微信/支付宝官方账单下载 adapter 应复用同一套 `commerce_reconciliation_batches/items`,下载后的 CSV/JSON 先规范化为 `rows`,再调用同一套匹配逻辑。官方账单自动下载、人工调整凭证附件、财务复核报表和异常订单运营台仍需要继续补。
B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务:

View File

@@ -117,8 +117,9 @@
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds``/api/commerce/refunds/status``/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
| 支付/退款补偿 worker | 可联调 | `apps/worker --job commerce` 查询微信/支付宝订单和处理中退款,补偿漏通知支付、补发权益、确认退款、全额退款撤销权益;`npm run test:worker:commerce` 覆盖幂等和密钥不泄露 |
| 资金流水对账 | 可联调 | `commerce_reconciliation_batches/items` + `/api/commerce/reconciliation/preview/import/batches/items/anomalies`;租户后台需 `tenant:reconciliation:read/write`,支持支付/退款账单行手工或 API 导入、来源 hash、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;本阶段只生成差异,不自动改订单/权益 |
| 官方账单下载和差错处理台 | 待补齐 | 后续补微信/支付宝账单下载、差错单处理流、人工调整凭证、财务复核和异常订单运营台 |
| 资金流水对账 | 可联调 | `commerce_reconciliation_batches/items` + `/api/commerce/reconciliation/preview/import/batches/items/anomalies`;租户后台需 `tenant:reconciliation:read/write`,支持支付/退款账单行手工或 API 导入、来源 hash、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;对账只生成差异,不自动改订单/权益 |
| 对账差错工单 | 可联调 | `commerce_reconciliation_issues/events` + `/api/commerce/reconciliation/issues*`;异常明细可创建工单,支持分配、开始处理、升级、解决、忽略、重开、事件轨迹和审计;处理结论只作为财务审核记录,不直接修改订单、支付、退款或权益 |
| 官方账单下载和异常运营台 | 待补齐 | 后续补微信/支付宝账单自动下载、人工调整凭证附件/复核、异常订单运营台和财务报表 |
## 租户后台与平台后台

View File

@@ -29,7 +29,7 @@
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架、题库导出 PDF/Word/每日一练 ZIP 可生成 `content_assets` 并走签名下载/预览 | CDN 防盗链、杀毒扫描、视频水印、资料前端操作体验 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API | 微信/支付宝官方账单自动下载、差错处理工单、财务复核和异常订单运营台 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、对账差错工单和事件轨迹 | 微信/支付宝官方账单自动下载、财务复核报表和异常订单运营台 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
@@ -83,7 +83,7 @@
### P1商用收费和运营能力
- 资金对账已支持手工/API 账单导入比对异常查询;继续补微信/支付宝官方账单下载、差错处理工单、财务复核和异常订单运营台。
- 资金对账已支持手工/API 账单导入比对异常查询和差错工单处理;继续补微信/支付宝官方账单下载、财务复核报表和异常订单运营台。
- XPay 或其它实际支付网关 adapter。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。

View File

@@ -144,6 +144,15 @@ POST /api/commerce/payments/create
POST /api/commerce/payments/manual-confirm
POST /api/commerce/payments/notify/wechat_pay
POST /api/commerce/payments/notify/alipay
POST /api/commerce/reconciliation/preview
POST /api/commerce/reconciliation/import
GET /api/commerce/reconciliation/batches
GET /api/commerce/reconciliation/items
GET /api/commerce/reconciliation/anomalies
POST /api/commerce/reconciliation/issues/create
GET /api/commerce/reconciliation/issues
POST /api/commerce/reconciliation/issues/status
GET /api/commerce/reconciliation/issues/events
POST /api/commerce/activation-codes/check
POST /api/commerce/activation-codes/redeem
POST /api/commerce/coupons/claim
@@ -242,7 +251,7 @@ GET /api/tenant-admin/audit-logs
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
- `/api/commerce/refunds``/api/commerce/refunds/status``/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook全额退款成功会撤销订单来源权益`apps/worker --job commerce` 会定时补偿查询支付和退款状态;`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询和审计。
- `/api/commerce/refunds``/api/commerce/refunds/status``/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook全额退款成功会撤销订单来源权益`apps/worker --job commerce` 会定时补偿查询支付和退款状态;`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询、差错工单状态流、事件轨迹和审计。
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/VaultAPI 只返回 `secretRef` 和掩码状态。
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动和勋章,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
@@ -260,7 +269,7 @@ GET /api/tenant-admin/audit-logs
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 完成真实 OAuth provider 联调微信网页、微信小程序、QQ确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
4. 补微信/支付宝官方账单自动下载、差错处理工单、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知资金对账导入比对主链路已完成。
4. 补微信/支付宝官方账单自动下载、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知资金对账导入比对和差错工单主链路已完成。
5. 扩展 `apps/worker`日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。
6. 开始 Taro scaffold`supabaseApi` 抽到跨端包或适配层。

View File

@@ -30,7 +30,7 @@
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、资金对账导入比对异常查询 | 官方账单自动下载、差错处理工单、异常订单运营台、服务商/平台代收模式 |
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、资金对账导入比对异常查询、差错工单和事件轨迹 | 官方账单自动下载、异常订单运营台、服务商/平台代收模式 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |

View File

@@ -27,7 +27,7 @@
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、每日一练九宫格 metadata、PDF/Word 运营版式、ZIP 图片素材包、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、每日一练导出 metadata、异步 PDF/Word/每日一练 ZIP job 创建、exports worker、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API、JSON/PDF/Word/每日一练 ZIP 基础导出可联调;公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 |
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询、支付/退款补偿、资金对账和差错工单已实现 | API 集成测试 | 商城主链路可联调,官方账单自动下载、异常订单运营台和生产账号联调待补 |
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 |
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
@@ -193,6 +193,15 @@ commerce:
POST /api/commerce/payments/manual-confirm
POST /api/commerce/payments/notify/wechat_pay
POST /api/commerce/payments/notify/alipay
POST /api/commerce/reconciliation/preview
POST /api/commerce/reconciliation/import
GET /api/commerce/reconciliation/batches
GET /api/commerce/reconciliation/items
GET /api/commerce/reconciliation/anomalies
POST /api/commerce/reconciliation/issues/create
GET /api/commerce/reconciliation/issues
POST /api/commerce/reconciliation/issues/status
GET /api/commerce/reconciliation/issues/events
POST /api/commerce/activation-codes/check
POST /api/commerce/activation-codes/redeem
POST /api/commerce/coupons/claim
@@ -275,10 +284,10 @@ platform-admin:
上线前至少还需要完成:
1. 正式鉴权API 已支持 Supabase Auth JWT生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id``x-platform-admin-key` 兼容入口。
2. 国内能力接入短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;真实生产账号联调、对账和支付补偿仍需继续完善。
2. 国内能力接入短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;资金对账、支付/退款补偿和差错工单已完成基础闭环,真实生产账号联调和官方账单下载仍需继续完善。
3. 核心缺口 API学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API下一步重点是学习报告可视化、后台统计和真实业务验收。
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、退款、支付补偿、资金对账、差错工单、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、官方账单下载和前端端到端测试。
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
@@ -291,4 +300,4 @@ platform-admin:
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
6. 接真实短信/OAuth 生产账号、退款对账和补偿任务,并开始 Taro scaffold。
6. 接真实短信/OAuth 生产账号、微信/支付宝官方账单下载和异常订单运营台,并继续推进 Taro scaffold。

View File

@@ -30,7 +30,7 @@
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对异常查询已有;缺微信/支付宝官方账单自动下载、差错处理工单、异常订单运营台和前端收银台/售后体验 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对异常查询、差错工单和事件轨迹已有;缺微信/支付宝官方账单自动下载、异常订单运营台和前端收银台/售后体验 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |

View File

@@ -27,7 +27,7 @@
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
- 租户后台数据看板已完成首版聚合 API`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
- 资金对账基础闭环已完成:`commerce_reconciliation_batches/items``/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志。
- 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items``/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益
- 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包写入对象存储或本地开发存储创建 `content_assets` 并回填 `assetId/hash/size``daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
- 本地验证:`npm run check:refactor` 已通过。
@@ -71,7 +71,7 @@
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。
- 已完成支付/退款补偿 worker可兜底供应商漏通知、处理中退款和重复执行幂等。
- 已完成资金对账手工/API 导入比对、批次/明细/异常查询和审计;继续补微信/支付宝官方账单自动下载、差错处理工单、人工调整凭证、财务复核和异常订单运营台。
- 已完成资金对账手工/API 导入比对、批次/明细/异常查询、差错工单状态流和审计;继续补微信/支付宝官方账单自动下载、人工调整凭证附件、财务复核报表和异常订单运营台。
- 租户自有商户收款和平台代收/服务商模式。
2. 国内登录和短信
@@ -110,7 +110,7 @@
8. 订单和营销体验
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
- 已完成支付/退款补偿 worker资金对账导入比对;继续补异常订单运营台、优惠券核销报表和复杂活动规则。
- 已完成支付/退款补偿 worker资金对账导入比对和差错工单;继续补异常订单运营台、优惠券核销报表和复杂活动规则。
9. 积分和反馈增强
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
@@ -225,5 +225,5 @@
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
6. 并行补真实登录、微信/支付宝官方账单自动下载与差错处理台、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -1752,11 +1752,11 @@ approved/processing -> failed
- 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=<tenantId>``POST /api/commerce/refunds/notify/alipay?tenantId=<tenantId>`。这是支付平台回调地址Taro 前端不要主动调用。
- 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。
- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。
- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。资金对账已支持租户后台手工/API 导入供应商账单查询差异;官方账单自动下载、差错处理工单和异常订单运营台后续继续补。生产联调时仍需保留人工确认/失败登记入口。
- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。资金对账已支持租户后台手工/API 导入供应商账单查询差异和差错工单处理;官方账单自动下载和异常订单运营台后续继续补。生产联调时仍需保留人工确认/失败登记入口。
### 租户后台资金对账
资金对账是租户后台/财务运营能力,学生端不要接。对账接口只生成差异台账和审计,不会自动修改订单、支付、退款或权益。前端不能根据对账结果自行开通、退款或撤销权益。
资金对账是租户后台/财务运营能力,学生端不要接。对账接口只生成差异台账、差错工单和审计,不会自动修改订单、支付、退款或权益。前端不能根据对账结果或工单状态自行开通、退款或撤销权益。
预览账单:
@@ -1804,6 +1804,78 @@ GET /api/commerce/reconciliation/items?batchId=<batchId>&matchStatus=missing_pro
GET /api/commerce/reconciliation/anomalies?provider=wechat_pay
```
从异常明细创建差错工单:
```text
POST /api/commerce/reconciliation/issues/create
权限tenant:reconciliation:write
body: {
"itemId": "<reconciliationItemId>",
"assignedTo": "<tenantStaffUserId可选>",
"dueAt": "2026-06-30T10:00:00.000Z",
"summary": "微信账单金额不一致核对",
"note": "先交给财务核对供应商流水",
"metadata": {
"source": "tenant-admin"
}
}
```
重复对同一未关闭异常明细创建工单时,后端会返回原工单并带 `idempotent=true``matched/ignored` 明细不可创建工单。
查询工单:
```text
GET /api/commerce/reconciliation/issues?status=open&assignedTo=<userId>&batchId=<batchId>&orderNo=<orderNo>
权限tenant:reconciliation:read
```
工单状态流转:
```text
POST /api/commerce/reconciliation/issues/status
权限tenant:reconciliation:write
body: {
"issueId": "<issueId>",
"action": "start | assign | resolve | ignore | escalate | reopen",
"assignedTo": "<userIdassign 时必填>",
"resolutionType": "provider_confirmed | local_corrected | manual_adjustment | false_positive | duplicate | write_off",
"note": "处理备注",
"metadata": {
"voucherNo": "ADJ-20260629-001"
}
}
```
查看事件轨迹:
```text
GET /api/commerce/reconciliation/issues/events?issueId=<issueId>
权限tenant:reconciliation:read
```
工单状态:
```text
open 新建待处理
investigating 处理中
escalated 已升级
resolved 已解决
ignored 已忽略
```
处理结论:
```text
none 未处理
provider_confirmed 已按供应商确认
local_corrected 已通过专门业务命令修正本地记录
manual_adjustment 已登记人工调整凭证
false_positive 误报
duplicate 重复账单/重复工单
write_off 财务核销
```
`matchStatus` 取值:
```text
@@ -1821,7 +1893,7 @@ ignored 无效行或不符合本次 billType
- 财务导入页建议使用 preview -> 人工确认 -> import -> anomalies 的流程。
- `sourceHash` 可作为同一文件内容的识别线索,但当前接口不会阻止重复导入;前端应展示最近同名/同 hash 批次提醒。
- 金额统一是分,前端不要传元。
- 对账差异只是运营判断依据,最终订单修正必须走退款、补偿、人工确认或后续差错处理接口。
- 对账差异和差错工单只是运营判断依据,`resolve/ignore` 不会落账。最终订单修正必须走退款、补偿、人工确认或后续专门的人工调整接口。
- 当前后端支持 JSON 行导入CSV/Excel 可以先由前端或后续后端 parser 转成上述 `rows`。微信/支付宝官方账单自动下载仍是后续后端任务。
### 激活码预检查与兑换

View File

@@ -2205,6 +2205,114 @@ async function testCommerce() {
'reconciliation anomalies endpoint should expose imported issues',
);
const actionableReconItem = reconciliationItems.items?.find(item => item.orderNo === reconMissingProviderOrder.item.orderNo);
assert.ok(actionableReconItem?.id, 'reconciliation test should have an actionable missing_provider item');
const studentReconciliationIssueDenied = await request('/api/commerce/reconciliation/issues/create', {
method: 'POST',
body: { itemId: actionableReconItem.id },
expectStatus: 403,
});
assert.equal(studentReconciliationIssueDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not create reconciliation issues');
const reconciliationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
itemId: actionableReconItem.id,
assignedTo: TENANT_OPERATOR_USER_ID,
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
summary: '人工核对供应商缺失账单',
note: '集成测试创建差错工单',
metadata: { source: 'api-integration-test' },
},
});
assert.ok(reconciliationIssue.item?.id, 'tenant admin should create a reconciliation issue');
assert.equal(reconciliationIssue.item?.status, 'open', 'created reconciliation issue should start open');
assert.equal(reconciliationIssue.item?.assignedTo, TENANT_OPERATOR_USER_ID, 'created issue should keep assignee');
assert.equal(reconciliationIssue.item?.matchStatus, 'missing_provider', 'created issue should copy match status');
const duplicateReconciliationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { itemId: actionableReconItem.id },
});
assert.equal(duplicateReconciliationIssue.item?.id, reconciliationIssue.item.id, 'duplicate active issue creation should be idempotent');
assert.equal(duplicateReconciliationIssue.idempotent, true, 'duplicate active issue creation should return idempotent=true');
const reconciliationIssues = await request('/api/commerce/reconciliation/issues', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'open', assignedTo: TENANT_OPERATOR_USER_ID },
});
assert.ok(
reconciliationIssues.items?.some(item => item.id === reconciliationIssue.item.id),
'reconciliation issues endpoint should filter by status and assignee',
);
const startedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'start',
note: '开始核查',
},
});
assert.equal(startedReconciliationIssue.item?.status, 'investigating', 'start action should mark issue investigating');
const assignedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'assign',
assignedTo: TENANT_ADMIN_USER_ID,
note: '转交财务管理员复核',
},
});
assert.equal(assignedReconciliationIssue.item?.assignedTo, TENANT_ADMIN_USER_ID, 'assign action should update assignee');
assert.equal(assignedReconciliationIssue.item?.status, 'investigating', 'assign action should not close issue');
const resolvedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'resolve',
resolutionType: 'manual_adjustment',
note: '已生成线下调整凭证',
metadata: { voucherNo: 'VOUCHER-RECON-001' },
},
});
assert.equal(resolvedReconciliationIssue.item?.status, 'resolved', 'resolve action should close issue');
assert.equal(resolvedReconciliationIssue.item?.resolutionType, 'manual_adjustment', 'resolve action should keep resolution type');
assert.equal(resolvedReconciliationIssue.item?.resolvedBy, TENANT_ADMIN_USER_ID, 'resolve action should record resolver');
const reconciliationIssueEvents = await request('/api/commerce/reconciliation/issues/events', {
userId: TENANT_ADMIN_USER_ID,
query: { issueId: reconciliationIssue.item.id },
});
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'created' && item.toStatus === 'open'),
'reconciliation issue events should include creation',
);
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'start' && item.toStatus === 'investigating'),
'reconciliation issue events should include start transition',
);
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'resolve' && item.toStatus === 'resolved'),
'reconciliation issue events should include resolution transition',
);
const crossTenantReconciliationIssueDenied = await request('/api/commerce/reconciliation/issues/events', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { issueId: reconciliationIssue.item.id },
expectStatus: 403,
});
assert.equal(crossTenantReconciliationIssueDenied.code, 'TENANT_ADMIN_REQUIRED', 'reconciliation issue events must be tenant isolated');
const crossTenantReconciliationDenied = await request('/api/commerce/reconciliation/items', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,

View File

@@ -0,0 +1,96 @@
create table if not exists public.commerce_reconciliation_issues (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
batch_id uuid references public.commerce_reconciliation_batches(id) on delete set null,
item_id uuid references public.commerce_reconciliation_items(id) on delete set null,
issue_no text not null,
provider text not null,
transaction_type text not null
check (transaction_type in ('payment', 'refund')),
issue_code text,
match_status text not null
check (match_status in ('amount_mismatch', 'status_mismatch', 'missing_local', 'missing_provider', 'duplicate')),
severity text not null default 'warning'
check (severity in ('info', 'warning', 'error', 'critical')),
status text not null default 'open'
check (status in ('open', 'investigating', 'resolved', 'ignored', 'escalated')),
resolution_type text not null default 'none'
check (resolution_type in ('none', 'provider_confirmed', 'local_corrected', 'manual_adjustment', 'false_positive', 'duplicate', 'write_off')),
order_id uuid references public.orders(id) on delete set null,
payment_id uuid references public.payments(id) on delete set null,
refund_request_id uuid references public.commerce_refund_requests(id) on delete set null,
order_no text,
refund_no text,
provider_trade_no text,
provider_refund_no text,
amount_cents integer not null default 0 check (amount_cents >= 0),
refund_amount_cents integer not null default 0 check (refund_amount_cents >= 0),
assigned_to uuid references public.platform_users(id) on delete set null,
created_by uuid references public.platform_users(id) on delete set null,
resolved_by uuid references public.platform_users(id) on delete set null,
resolved_at timestamptz,
due_at timestamptz,
summary text,
resolution_note text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, issue_no)
);
create table if not exists public.commerce_reconciliation_issue_events (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
issue_id uuid not null references public.commerce_reconciliation_issues(id) on delete cascade,
from_status text,
to_status text,
event_type text not null,
actor_user_id uuid references public.platform_users(id) on delete set null,
note text,
details jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
check (from_status is null or from_status in ('open', 'investigating', 'resolved', 'ignored', 'escalated')),
check (to_status is null or to_status in ('open', 'investigating', 'resolved', 'ignored', 'escalated'))
);
create unique index if not exists idx_commerce_recon_issues_active_item
on public.commerce_reconciliation_issues(tenant_id, item_id)
where item_id is not null and status in ('open', 'investigating', 'escalated');
create index if not exists idx_commerce_recon_issues_tenant_status
on public.commerce_reconciliation_issues(tenant_id, status, severity, created_at desc);
create index if not exists idx_commerce_recon_issues_assigned
on public.commerce_reconciliation_issues(tenant_id, assigned_to, status, due_at);
create index if not exists idx_commerce_recon_issues_batch
on public.commerce_reconciliation_issues(tenant_id, batch_id, status, created_at desc);
create index if not exists idx_commerce_recon_issues_order
on public.commerce_reconciliation_issues(tenant_id, order_no, created_at desc);
create index if not exists idx_commerce_recon_issues_payment
on public.commerce_reconciliation_issues(tenant_id, payment_id, created_at desc);
create index if not exists idx_commerce_recon_issue_events_issue
on public.commerce_reconciliation_issue_events(tenant_id, issue_id, created_at desc);
alter table public.commerce_reconciliation_issues enable row level security;
alter table public.commerce_reconciliation_issue_events enable row level security;
drop policy if exists tenant_isolation on public.commerce_reconciliation_issues;
create policy tenant_isolation on public.commerce_reconciliation_issues
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.commerce_reconciliation_issue_events;
create policy tenant_isolation on public.commerce_reconciliation_issue_events
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop trigger if exists set_updated_at on public.commerce_reconciliation_issues;
create trigger set_updated_at
before update on public.commerce_reconciliation_issues
for each row execute function app.touch_updated_at();