Files
wordpress-theme-aksrt/tests/widget-system/financial-flow-contract.test.cjs
T
kate522andClaude Opus 5 dbb7de41e9 feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 23:29:51 +08:00

212 lines
14 KiB
JavaScript

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; ticket center replaces the after-sale 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 与页面整体删除
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')
// 售后整体移除:工单改走 users.php 的「工单管理」子菜单,支付中心不再承载售后
assert.ok(!menus.includes('aksrt_after_sales'), 'after-sales submenu slug must be gone')
assert.ok(!page.includes('render_after_sales_admin_page'), 'after-sales admin page entry must be gone')
assert.ok(!page.includes('PAGE_AFTER_SALES_SLUG'), 'after-sales slug constant must be gone')
assert.ok(!page.includes('after_sale'), 'no after-sale handler may remain on the orders page')
const usersMenus = read('src/Admin/Native/UsersMenus.php')
assert.ok(usersMenus.includes("'aksrt_tickets'"), 'ticket submenu slug registered under users.php')
assert.ok(usersMenus.includes('render_tickets_page'), 'ticket admin page entry registered')
const ticketTrait = read('src/Admin/Native/UsersMenusTicketsCenter.php')
assert.ok(ticketTrait.includes('use UsersMenusTicketsList;'), 'ticket list renderer trait composed')
assert.ok(ticketTrait.includes('use UsersMenusTicketsConfig;'), 'ticket config renderer trait composed')
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, /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('paid orders expose a refund button next to the detail link', () => {
const page = read('src/Admin/Native/PayOrders.php')
// 「退款」紧跟在「详情」右侧,仅已支付订单可见——已退款是已支付的唯一后继状态
assert.match(page, /详情', 'aksrt'\) \. '<\/a>';\s*\n\s*\/\/ 已支付是已退款的唯一前置状态[\s\S]*?aksrt_pay_refund_order/)
assert.ok(page.includes("if ($status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
'refund button only renders for paid orders')
assert.ok(page.includes('aksrt_pay_refund_order_'), 'per-order nonce covers the refund')
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order'])"),
'refund action registered')
assert.ok(page.includes('handle_refund_order'), 'refund handler exists')
// 退款闸门只剩「订单为已支付」:工单系统与订单退款已解耦,不再要求先有进行中的工单
assert.ok(!page.includes('AfterSaleService'), 'no after-sale lookup in the refund path')
assert.ok(!page.includes('after_sale_id'), 'refund is no longer bound to an after-sale application')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.ok(!repository.includes('can_finalize_order_refund'),
'OrderRepository no longer gates refunds on an after-sale application')
// 各种失败都要给出可操作的提示,而不是静默失败
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid']) {
assert.ok(page.includes(`'${code}'`), `flash code ${code} is mapped`)
}
assert.ok(!page.includes('order_refund_no_ticket'), 'the no-ticket refund failure is gone')
})
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/)
}
})