forked from wangziqi/gongxue-base
feat: add reconciliation issue workflow
This commit is contained in:
10
README.md
10
README.md
@@ -22,14 +22,14 @@
|
|||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||||
- 资金对账基础闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志。
|
- 资金对账和差错工单闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕。工单只做财务审核闭环,不直接修改订单、支付、退款或权益。
|
||||||
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
||||||
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
|
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
|
||||||
|
|
||||||
还没有达到生产交付的部分:
|
还没有达到生产交付的部分:
|
||||||
|
|
||||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对,微信/支付宝官方账单自动下载、差错处理工单和真实生产账号联调还没接完。
|
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对和差错工单处理,微信/支付宝官方账单自动下载、异常订单运营台和真实生产账号联调还没接完。
|
||||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成,杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
|
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成,杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
|
||||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
@@ -278,7 +278,7 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失
|
|||||||
apps/api/src/features/
|
apps/api/src/features/
|
||||||
auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录
|
auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录
|
||||||
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
|
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
|
||||||
commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账
|
commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账和差错工单
|
||||||
health/ 健康检查
|
health/ 健康检查
|
||||||
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
|
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
|
||||||
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
|
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
|
||||||
@@ -309,7 +309,7 @@ API 身份上下文:
|
|||||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
||||||
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
||||||
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定期通过资金对账接口导入供应商账单核对本地订单。
|
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定期通过资金对账接口导入供应商账单核对本地订单。对账差错工单只允许记录财务处理结论和凭证,不允许前端或工单接口直接篡改订单、支付、退款或权益状态。
|
||||||
|
|
||||||
## 最近一次验证
|
## 最近一次验证
|
||||||
|
|
||||||
@@ -338,4 +338,4 @@ git diff --check
|
|||||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
||||||
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
||||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||||
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载与差错处理台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载、异常订单运营台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ import {
|
|||||||
updateRefundStatusRoute,
|
updateRefundStatusRoute,
|
||||||
} from './routes.js';
|
} from './routes.js';
|
||||||
import {
|
import {
|
||||||
|
createReconciliationIssueRoute,
|
||||||
importReconciliationRoute,
|
importReconciliationRoute,
|
||||||
previewReconciliationRoute,
|
previewReconciliationRoute,
|
||||||
reconciliationAnomaliesRoute,
|
reconciliationAnomaliesRoute,
|
||||||
reconciliationBatchesRoute,
|
reconciliationBatchesRoute,
|
||||||
|
reconciliationIssueEventsRoute,
|
||||||
|
reconciliationIssuesRoute,
|
||||||
reconciliationItemsRoute,
|
reconciliationItemsRoute,
|
||||||
|
updateReconciliationIssueStatusRoute,
|
||||||
} from './reconciliation.js';
|
} from './reconciliation.js';
|
||||||
|
|
||||||
export const commerceRoutes: RouteDefinition[] = [
|
export const commerceRoutes: RouteDefinition[] = [
|
||||||
@@ -48,6 +52,10 @@ export const commerceRoutes: RouteDefinition[] = [
|
|||||||
['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute],
|
['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute],
|
||||||
['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute],
|
['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute],
|
||||||
['GET', '/api/commerce/reconciliation/anomalies', reconciliationAnomaliesRoute],
|
['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/check', checkActivationCodeRoute],
|
||||||
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
|
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
|
||||||
['POST', '/api/commerce/coupons/claim', claimCouponRoute],
|
['POST', '/api/commerce/coupons/claim', claimCouponRoute],
|
||||||
|
|||||||
@@ -19,9 +19,37 @@ type MatchStatus =
|
|||||||
| 'duplicate'
|
| 'duplicate'
|
||||||
| 'ignored';
|
| 'ignored';
|
||||||
type Severity = 'info' | 'warning' | 'error' | 'critical';
|
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 BILL_TYPES = new Set<BillType>(['payment', 'refund', 'combined']);
|
||||||
const SOURCES = new Set<ReconciliationSource>(['manual_upload', 'provider_download', 'api', 'worker']);
|
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([
|
const PROVIDER_SUCCESS_STATUSES = new Set([
|
||||||
'success',
|
'success',
|
||||||
'succeeded',
|
'succeeded',
|
||||||
@@ -180,6 +208,32 @@ function objectValue(value: unknown): RawBillRow {
|
|||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as 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[]) {
|
function optionalRowString(row: RawBillRow, keys: string[]) {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = row[key];
|
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>) {
|
async function recordReconciliationAudit(client: pg.PoolClient, auth: TenantAdminAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
|
||||||
await client.query(
|
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) {
|
async function authorizeRead(ctx: RequestContext) {
|
||||||
const auth = await requireTenantAdmin(ctx);
|
const auth = await requireTenantAdmin(ctx);
|
||||||
requireTenantPermission(auth, 'tenant:reconciliation:read');
|
requireTenantPermission(auth, 'tenant:reconciliation:read');
|
||||||
@@ -1163,3 +1363,394 @@ export async function reconciliationAnomaliesRoute(ctx: RequestContext) {
|
|||||||
paymentEventErrors,
|
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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/batches`:查询对账批次。
|
||||||
- `GET /api/commerce/reconciliation/items`:查询逐行结果。
|
- `GET /api/commerce/reconciliation/items`:查询逐行结果。
|
||||||
- `GET /api/commerce/reconciliation/anomalies`:查询金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行等异常。
|
- `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: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`,而是走平台账务:
|
B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务:
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,9 @@
|
|||||||
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
||||||
| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`、`/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
| 退款状态机和供应商确认 | 可联调 | `/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` 覆盖幂等和密钥不泄露 |
|
| 支付/退款补偿 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*`;异常明细可创建工单,支持分配、开始处理、升级、解决、忽略、重开、事件轨迹和审计;处理结论只作为财务审核记录,不直接修改订单、支付、退款或权益 |
|
||||||
|
| 官方账单下载和异常运营台 | 待补齐 | 后续补微信/支付宝账单自动下载、人工调整凭证附件/复核、异常订单运营台和财务报表 |
|
||||||
|
|
||||||
## 租户后台与平台后台
|
## 租户后台与平台后台
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||||
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
|
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
|
||||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架、题库导出 PDF/Word/每日一练 ZIP 可生成 `content_assets` 并走签名下载/预览 | CDN 防盗链、杀毒扫描、视频水印、资料前端操作体验 |
|
| 资料下载 | 部分完成 | 资源台账、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 配置表 | 真实生产账号和回调域名联调 |
|
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||||
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
|
| 销售/代理/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 执行验收、抽样校验和导入性能压测 |
|
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 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:商用收费和运营能力
|
### P1:商用收费和运营能力
|
||||||
|
|
||||||
- 资金对账已支持手工/API 账单导入比对和异常查询;继续补微信/支付宝官方账单下载、差错处理工单、财务复核和异常订单运营台。
|
- 资金对账已支持手工/API 账单导入比对、异常查询和差错工单处理;继续补微信/支付宝官方账单下载、财务复核报表和异常订单运营台。
|
||||||
- XPay 或其它实际支付网关 adapter。
|
- XPay 或其它实际支付网关 adapter。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
||||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ POST /api/commerce/payments/create
|
|||||||
POST /api/commerce/payments/manual-confirm
|
POST /api/commerce/payments/manual-confirm
|
||||||
POST /api/commerce/payments/notify/wechat_pay
|
POST /api/commerce/payments/notify/wechat_pay
|
||||||
POST /api/commerce/payments/notify/alipay
|
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/check
|
||||||
POST /api/commerce/activation-codes/redeem
|
POST /api/commerce/activation-codes/redeem
|
||||||
POST /api/commerce/coupons/claim
|
POST /api/commerce/coupons/claim
|
||||||
@@ -242,7 +251,7 @@ GET /api/tenant-admin/audit-logs
|
|||||||
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
||||||
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
||||||
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
|
- `/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/Vault,API 只返回 `secretRef` 和掩码状态。
|
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
||||||
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
||||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动和勋章,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动和勋章,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||||
@@ -260,7 +269,7 @@ GET /api/tenant-admin/audit-logs
|
|||||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。
|
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。
|
||||||
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||||
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
||||||
4. 补微信/支付宝官方账单自动下载、差错处理工单、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知和资金对账导入比对主链路已完成。
|
4. 补微信/支付宝官方账单自动下载、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、资金对账导入比对和差错工单主链路已完成。
|
||||||
5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。
|
5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。
|
||||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
||||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||||
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||||
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、资金对账导入比对和异常查询 | 官方账单自动下载、差错处理工单、异常订单运营台、服务商/平台代收模式 |
|
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、资金对账导入比对、异常查询、差错工单和事件轨迹 | 官方账单自动下载、异常订单运营台、服务商/平台代收模式 |
|
||||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
|
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
|
||||||
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |
|
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 基础导出可联调;公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 |
|
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射,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` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||||
| 收藏夹 | 已建 `favorite_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、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 |
|
||||||
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
||||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
||||||
@@ -193,6 +193,15 @@ commerce:
|
|||||||
POST /api/commerce/payments/manual-confirm
|
POST /api/commerce/payments/manual-confirm
|
||||||
POST /api/commerce/payments/notify/wechat_pay
|
POST /api/commerce/payments/notify/wechat_pay
|
||||||
POST /api/commerce/payments/notify/alipay
|
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/check
|
||||||
POST /api/commerce/activation-codes/redeem
|
POST /api/commerce/activation-codes/redeem
|
||||||
POST /api/commerce/coupons/claim
|
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` 兼容入口。
|
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;下一步重点是学习报告可视化、后台统计和真实业务验收。
|
3. 核心缺口 API:学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API;下一步重点是学习报告可视化、后台统计和真实业务验收。
|
||||||
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;前端操作台待补。
|
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;前端操作台待补。
|
||||||
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
|
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、退款、支付补偿、资金对账、差错工单、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、官方账单下载和前端端到端测试。
|
||||||
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
|
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
|
||||||
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
|
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
|
||||||
|
|
||||||
@@ -291,4 +300,4 @@ platform-admin:
|
|||||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||||
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
|
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
|
||||||
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
|
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
|
||||||
6. 接真实短信/OAuth 生产账号、退款对账和补偿任务,并开始 Taro scaffold。
|
6. 接真实短信/OAuth 生产账号、微信/支付宝官方账单下载和异常订单运营台,并继续推进 Taro scaffold。
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
||||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
||||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
|
| 分数线 | `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 已有;缺学习报告可视化 |
|
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
|
||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
||||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
- 支付/退款补偿 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 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。
|
- 题库导出 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` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过。
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
||||||
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。
|
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。
|
||||||
- 已完成支付/退款补偿 worker,可兜底供应商漏通知、处理中退款和重复执行幂等。
|
- 已完成支付/退款补偿 worker,可兜底供应商漏通知、处理中退款和重复执行幂等。
|
||||||
- 已完成资金对账手工/API 导入比对、批次/明细/异常查询和审计;继续补微信/支付宝官方账单自动下载、差错处理工单、人工调整凭证、财务复核和异常订单运营台。
|
- 已完成资金对账手工/API 导入比对、批次/明细/异常查询、差错工单状态流和审计;继续补微信/支付宝官方账单自动下载、人工调整凭证附件、财务复核报表和异常订单运营台。
|
||||||
- 租户自有商户收款和平台代收/服务商模式。
|
- 租户自有商户收款和平台代收/服务商模式。
|
||||||
|
|
||||||
2. 国内登录和短信
|
2. 国内登录和短信
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
8. 订单和营销体验
|
8. 订单和营销体验
|
||||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||||
- 已完成支付/退款补偿 worker 和资金对账导入比对;继续补异常订单运营台、优惠券核销报表和复杂活动规则。
|
- 已完成支付/退款补偿 worker、资金对账导入比对和差错工单;继续补异常订单运营台、优惠券核销报表和复杂活动规则。
|
||||||
|
|
||||||
9. 积分和反馈增强
|
9. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
@@ -225,5 +225,5 @@
|
|||||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||||
6. 并行补真实登录、微信/支付宝官方账单自动下载与差错处理台、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -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 前端不要主动调用。
|
- 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=<tenantId>`、`POST /api/commerce/refunds/notify/alipay?tenantId=<tenantId>`。这是支付平台回调地址,Taro 前端不要主动调用。
|
||||||
- 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。
|
- 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。
|
||||||
- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。
|
- 已经 `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
|
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": "<userId,assign 时必填>",
|
||||||
|
"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` 取值:
|
`matchStatus` 取值:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -1821,7 +1893,7 @@ ignored 无效行或不符合本次 billType
|
|||||||
- 财务导入页建议使用 preview -> 人工确认 -> import -> anomalies 的流程。
|
- 财务导入页建议使用 preview -> 人工确认 -> import -> anomalies 的流程。
|
||||||
- `sourceHash` 可作为同一文件内容的识别线索,但当前接口不会阻止重复导入;前端应展示最近同名/同 hash 批次提醒。
|
- `sourceHash` 可作为同一文件内容的识别线索,但当前接口不会阻止重复导入;前端应展示最近同名/同 hash 批次提醒。
|
||||||
- 金额统一是分,前端不要传元。
|
- 金额统一是分,前端不要传元。
|
||||||
- 对账差异只是运营判断依据,最终订单修正必须走退款、补偿、人工确认或后续差错处理接口。
|
- 对账差异和差错工单只是运营判断依据,`resolve/ignore` 不会落账。最终订单修正必须走退款、补偿、人工确认或后续专门的人工调整接口。
|
||||||
- 当前后端支持 JSON 行导入;CSV/Excel 可以先由前端或后续后端 parser 转成上述 `rows`。微信/支付宝官方账单自动下载仍是后续后端任务。
|
- 当前后端支持 JSON 行导入;CSV/Excel 可以先由前端或后续后端 parser 转成上述 `rows`。微信/支付宝官方账单自动下载仍是后续后端任务。
|
||||||
|
|
||||||
### 激活码预检查与兑换
|
### 激活码预检查与兑换
|
||||||
|
|||||||
@@ -2205,6 +2205,114 @@ async function testCommerce() {
|
|||||||
'reconciliation anomalies endpoint should expose imported issues',
|
'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', {
|
const crossTenantReconciliationDenied = await request('/api/commerce/reconciliation/items', {
|
||||||
tenantId: PARTNER_TENANT_ID,
|
tenantId: PARTNER_TENANT_ID,
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user