Files
wordpress-theme-aksrt/tests/widget-system/financial-flow-contract.test.cjs
T
kate522 5fd8c6fc31 feat(after-sale): 售后面板新增「标记订单已退款」勾选项
展开的操作面板在状态下拉与备注旁新增「标记订单已退款」勾选框:勾选后点击确认,会先把关联订单推到已退款,再更新售后工单状态。订单本已退款时勾选框呈现为不可改的已选状态,且不提交该标记。

顺序不能反:OrderRepository::update_status 的退款闸门要求售后工单仍为待审核/处理中,一旦 set_status 先执行就不再放行。因此退款失败时整次操作中止,避免出现「工单已关、订单未退」的半完成状态。订单号由服务端按 after_sale_id 反查,不经客户端传递。

AfterSaleService::get() 由 private 改为 public 以支撑该反查。dev harness 扩展为可驱动 handler 的六个场景,覆盖勾选/未勾选、重复退款、非已支付订单、已关闭工单与非法状态。
2026-10-08 21:03:23 +08:00

230 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '../..')
const read = file => fs.readFileSync(path.join(root, file), 'utf8')
test('financial flow and withdrawal numbers use the new-only schema', () => {
const schema = read('src/Core/DBMigratorPart2.php')
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow \([\s\S]*?UNIQUE KEY uniq_flow_number \(flow_number\)/)
assert.match(schema, /UNIQUE KEY uniq_flow_operation \(operation_key\)/)
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow_entry \([\s\S]*?UNIQUE KEY uniq_flow_line \(flow_id, line_no\)/)
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_withdrawal \([\s\S]*?withdrawal_number VARCHAR\(32\) NOT NULL[\s\S]*?UNIQUE KEY uniq_withdrawal_number \(withdrawal_number\)/)
})
test('order, flow and withdrawal numbers share one prefix-free allocator', () => {
const jsSchema = read('assets/admin/src/schema/sections/payment/gateway.js')
const phpSchema = read('src/Admin/Schema/SchemaPayment.php')
const provider = read('src/Admin/SchemaProvider.php')
const runtime = read('src/Settings/PaymentRuntime.php')
const number = read('src/Finance/BusinessNumber.php')
// 三个前缀配置项及其校验/归一化链路必须彻底消失。
for (const source of [jsSchema, phpSchema]) {
assert.doesNotMatch(source, /payment_order_prefix|payment_flow_prefix|payment_withdrawal_prefix/)
assert.doesNotMatch(source, /business_prefix/)
}
assert.doesNotMatch(provider, /business_prefix|normalize_prefix/)
assert.doesNotMatch(runtime, /payment_order_prefix|payment_flow_prefix|payment_withdrawal_prefix/)
assert.doesNotMatch(runtime, /['"](prefix|flow_prefix|withdrawal_prefix)['"]\s*=>/)
assert.doesNotMatch(number, /normalize_prefix|MAX_PREFIX_LENGTH|candidate\(/)
// 唯一入口:无前缀的统一流水号分配器。
assert.match(number, /public static function allocate\(\?\\?DateTimeInterface/)
assert.match(number, /YmdHis/)
// 订单号、资金流水号、提现单号、卡密渠道交易号全部收敛到该分配器。
assert.match(read('src/Payment/OrderRepositoryCore.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Finance/FinancialFlowService.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Payment/Withdrawal.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Payment/ChargeCard.php'), /BusinessNumber::allocate\(\)/)
})
test('commission ledger and withdrawals retain their flow while order flows are removed', () => {
const ledger = read('src/Ledger/LedgerService.php')
const order = read('src/Payment/OrderRepositoryCore.php')
const withdrawal = read('src/Payment/Withdrawal.php')
assert.ok(ledger.indexOf('record_ledger(') < ledger.indexOf("query('COMMIT')"))
assert.doesNotMatch(order, /record_order_(?:payment|refund)/)
assert.match(withdrawal, /record_withdrawal_payout\([\s\S]*?true[\s\S]*?query\('COMMIT'\)/)
})
test('orders have no independent flow page or visible flow number', () => {
const menu = read('src/Admin/Native/PayMenus.php')
const payOrders = read('src/Admin/Native/PayOrders.php')
assert.doesNotMatch(menu, /PayFlows|流水明细/)
assert.equal(fs.existsSync(path.join(root, 'src/Admin/Native/PayFlows.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowQuery.php')), false)
assert.match(payOrders, /订单明细/)
assert.doesNotMatch(payOrders, /账本流水号|资金流水号/)
assert.match(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('订单号'/)
assert.doesNotMatch(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('流水号'|提现单号/)
// 前台订单详情只展示公开订单号,账本流水号不下发也不展示。
assert.match(read('assets/js/order-center.js'), /\["订单号", order\.order_number\]/)
assert.doesNotMatch(read('assets/js/order-center.js'), /flow_number|账本流水号/)
assert.doesNotMatch(read('src/Payment/UserOrders.php'), /\$view\['flow_number'\]/)
})
test('ledger history carries no financial flow coupling', () => {
const query = read('src/Ledger/LedgerQuery.php')
const records = [
read('inc/helpers/user-center-renderers/credits-transactions.php'),
read('inc/helpers/user-center-renderers/credits-wallet-ledger.php'),
].join('\n')
// 前台余额/积分记录只展示中文变动原因与变动额:提现单号既不查询也不下发,
// 账本历史不再为了它 LEFT JOIN financial_flow。
assert.doesNotMatch(query, /financial_flow|withdrawal_number/)
assert.doesNotMatch(records, /\$row\['ref_key'\]|\$row\['remark'\]|withdrawal_number|uc-transactions__remark/)
})
test('user withdrawal history uses one paginated ten-item endpoint', () => {
const controller = read('src/Payment/CommissionLedger.php')
const renderer = read('inc/helpers/user-center-renderers/credits-wallet.php')
const script = read('assets/js/credits.js')
const styles = read('assets/css/routes/user-center/credits.css')
assert.match(controller, /\$withdrawal_args\['per_page'\]\['default'\]\s*=\s*10/)
assert.match(controller, /\$withdrawal_args\['per_page'\]\['maximum'\]\s*=\s*10/)
assert.match(controller, /'total'\s*=>\s*\$total/)
assert.match(controller, /'max_pages'\s*=>\s*\$max_pages/)
assert.match(controller, /'user_id'\s*=>\s*\$user_id/)
assert.doesNotMatch(renderer, /get_user_withdrawals\(/)
assert.doesNotMatch(renderer, /withdraw_history/)
assert.match(renderer, /data-aksrt-withdraw-history-list/)
assert.match(script, /\/earnings\/withdrawals\?page=/)
assert.match(script, /&per_page=10/)
assert.match(script, /data-aksrt-withdraw-history-page/)
assert.match(script, /实际到账:/)
assert.match(script, /元)/)
assert.match(script, /appendText\(row, text\(item && item\.created_at\)/)
assert.doesNotMatch(script, /item\.note/)
assert.doesNotMatch(styles, /uc-withdraw-history__note/)
assert.match(styles, /\.uc-withdraw-history-modal__dialog\s*\{[\s\S]*?max-width: min\(860px/)
assert.match(styles, /\.uc-withdraw-history__item\s*\{[\s\S]*?display: flex/)
assert.match(styles, /\.uc-withdraw-history-modal__dialog\s*\{[\s\S]*?overflow: hidden/)
})
test('flow table is indexed for the admin listing and date range filters', () => {
const schema = read('src/Core/DBMigratorPart2.php')
// 后台默认视图是 WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT n,外加同条件的
// COUNT(*),界面上的日期区间筛选也只有 created_at 可用;created_at 必须是索引前导列,
// 否则每次翻页都是全表扫描 + filesort。
assert.match(schema, /KEY idx_flow_created \(created_at\),/)
// 复合索引前导列是 event_type(筛选时可空),替代不了上面那条。
assert.match(schema, /KEY idx_flow_event_time \(event_type, created_at\),/)
})
test('flow retention and archive were deliberately removed', () => {
// 归档方案(归档表 + 每日清理 cron)已移除:一条 created_at 索引覆盖了它要解决的问题,
// 而它会在财务表上执行 DELETE,并需要额外的归档浏览入口才闭环。
assert.doesNotMatch(read('src/Core/DBMigratorPart2.php'), /financial_flow_archive/)
assert.doesNotMatch(read('src/Core/DBMigratorPart3.php'), /financial_flow_archive/)
assert.doesNotMatch(read('functions.php'), /FinancialFlowRetention/)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
})
test('order detail page drops tabs; after-sales becomes a standalone submenu', () => {
const page = read('src/Admin/Native/PayOrders.php')
const menus = read('src/Admin/Native/PayMenus.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除,售后 Tab 移出
assert.ok(!page.includes('render_workbench_tabs'), 'workbench tabs must be removed')
assert.ok(!page.includes("$_GET['tab']"), 'tab query param must not be read')
assert.ok(!page.includes('render_statistics_page'), 'statistics page must be removed')
assert.ok(!page.includes('stat_card'), 'stat cards must be removed')
assert.ok(!repository.includes('get_admin_order_stats'), 'dead stats query must be removed')
// 售后独立为子菜单「售后服务」(aksrt_after_sales),无兼容跳转
assert.ok(menus.includes("'slug' => 'aksrt_after_sales'"), 'after-sales submenu slug')
assert.ok(page.includes('render_after_sales_admin_page'), 'after-sales admin page entry')
assert.ok(page.includes("PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales'"), 'after-sales slug constant')
assert.ok(!page.includes('after-sales'), 'old tab slug must not remain')
// 售后表格列对齐售后策略的申请表单配置项:原因 / 申请资料 / 申请说明
assert.ok(page.includes('<th>申请资料</th>'), 'materials column')
assert.ok(page.includes('<th>申请说明</th>'), 'description column')
assert.ok(!page.includes('<th>举证图片</th>'), 'evidence images must not be a standalone column')
assert.ok(page.includes('aksrt-as-desc-thumbs'), 'evidence thumbnails render inside the description cell')
// 操作列:点击「操作」以手风琴展开整条记录详情(含完整字段与状态面板),经 JS 以 POST 提交
assert.ok(page.includes('aksrt-toggle-row-actions'), 'toggle to expand record accordion')
assert.ok(page.includes('aksrt-as-detail-row'), 'accordion detail row per after-sale record')
assert.ok(page.includes('aksrt-as-detail-grid'), 'accordion panel renders full record fields')
assert.ok(page.includes('aksrt-review-note'), 'review note textarea inside expanded panel')
assert.ok(page.includes('aksrt-as-review-actions'), 'review actions row inside expanded panel')
assert.ok(page.includes('aksrt-status-select'), 'status select inside expanded panel')
assert.ok(page.includes('aksrt-after-sale-confirm'), 'confirm button posts the selected status')
assert.ok(page.includes('aksrt-after-sale-cancel'), 'cancel button only collapses the panel')
assert.ok(page.includes('aksrt_after_sale_update'), 'status update handler registered')
assert.ok(page.includes('aksrt_after_sale_status_'), 'per-row nonce covers the status update')
assert.ok(page.includes('aksrt-as-field--wide'), 'description renders as a full-width row')
assert.ok(page.includes('justify-content:flex-end'), 'confirm/cancel buttons right-aligned')
// 「标记订单已退款」:勾选后确认时把关联订单推到已退款
assert.ok(page.includes('aksrt-mark-order-refunded'), 'refund checkbox inside expanded panel')
assert.ok(page.includes('标记订单已退款'), 'refund checkbox label')
assert.ok(page.includes('mark_order_refunded:markRefund?"1":""'), 'refund flag posted only when checked')
assert.ok(page.includes('var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'),
'a disabled (already refunded) checkbox submits nothing')
assert.ok(page.includes('aksrt-as-refund-flag'), 'refund checkbox keeps its own row in the panel')
// 订单退款必须早于售后状态更新:工单一离开 pending/processing,OrderRepository 的退款闸门就不再放行
assert.ok(page.indexOf('mark_related_order_refunded($id)') < page.indexOf('set_status($id'),
'order refund runs before the after-sale status update')
assert.ok(page.includes('DomainModel::ORDER_STATUS_REFUNDED'), 'refund marking targets the refunded order status')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
assert.ok(page.includes("'after_sale_id' => $after_sale_id"), 'refund is bound to this after-sale application')
assert.ok(page.includes('after_sale_order_refund_failed'), 'refund failure is reported to the admin')
assert.ok(page.includes('after_sale_updated_order_refunded'), 'refund success is reported to the admin')
const service = read('src/Payment/AfterSaleService.php')
assert.match(service, /public function get\(int \$id\)/, 'after-sale lookup is exposed for the admin handler')
assert.ok(page.includes('.aksrt-as-detail-row[hidden]'), 'hidden attribute guard keeps accordion closed')
assert.ok(page.includes('document.addEventListener("click"'), 'delegated click handler survives row re-render')
assert.ok(!page.includes('class="row-actions"'), 'no hover-only row-actions')
assert.ok(!page.includes('aksrt_after_sale_bulk'), 'bulk review handler removed')
assert.ok(!page.includes('批量操作'), 'bulk actions dropdown removed')
assert.ok(!page.includes('after_sale_ids[]'), 'row checkboxes removed')
assert.ok(page.includes('$materials_cell'), 'materials cell rendered from apply_form')
assert.ok(page.includes('$desc_cell'), 'description cell rendered from apply_form')
// 页头展示售后策略摘要(功能开关 / 时效 / 预置原因 / 申请材料)
assert.ok(page.includes('get_after_sale_settings()'), 'policy summary reads settings')
assert.ok(page.includes('预置原因'), 'policy summary shows reasons')
assert.ok(page.includes('申请所需材料'), 'policy summary shows materials')
const domain = read('src/Core/DomainModel.php')
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_COMPLETED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED/)
assert.ok(!page.includes('STATUS_APPROVED'), 'approved status removed')
assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/)
})
test('order creation retries only on order-number unique key collisions', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.match(repository, /private const ORDER_NUMBER_MAX_ATTEMPTS = \d+;/)
assert.match(repository, /for \(\$attempt = 1; \$attempt <= \$max_attempts; \$attempt\+\+\)/)
// 只对订单号唯一键冲突重试;request_key 等冲突属于调用方幂等语义,必须原样失败。
assert.match(repository, /stripos\(\(string\) \$wpdb->last_error, 'uniq_order_number'\)/)
// 调用方显式传入的合规订单号不得换号重试。
assert.match(repository, /\$max_attempts = \$caller_supplied \? 1 : self::ORDER_NUMBER_MAX_ATTEMPTS;/)
})
test('caller-supplied order numbers are constrained to allocator shapes', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
// 22 位是当前规则,20 位兼容扩位前的历史号码(导入旧数据 / 旧单重放)。
// 用精确字符串断言:正则字面量里转义 \d 与花括号极易写错,会静默失配。
assert.ok(repository.includes("ORDER_NUMBER_PATTERN = '/^\\d{20}$|^\\d{22}$/';"))
// 非标量(数组 / 对象)不得进入 (string) 转换,否则会写入 "Array"。
assert.match(repository, /is_scalar\(\$data\['order_number'\]\)/)
// 不合规时必须回落到分配器,而不是原样写库。
assert.match(repository, /\$order_number = \$caller_supplied \? \$declared_number : \$this->allocate_order_number\(\);/)
// 弃用不受控号码时在 WP_DEBUG 下留痕。
assert.match(repository, /error_log\(sprintf\('\[AKSRT Order\] 弃用不合规的自带订单号:%s'/)
})
test('order cleanup no longer guards on financial records', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
const payOrders = read('src/Admin/Native/PayOrders.php')
assert.match(repository, /public function clear_closed_orders\(int \$days = 14\): int/)
assert.match(repository, /public function delete_order\(string \$order_number\): bool/)
for (const source of [repository, payOrders]) {
assert.doesNotMatch(source, /has_financial_records|count_closed_orders_with_records|financial_records_exists_sql/)
assert.doesNotMatch(source, /orders_clear_blocked|order_delete_blocked/)
}
})