展开的操作面板在状态下拉与备注旁新增「标记订单已退款」勾选框:勾选后点击确认,会先把关联订单推到已退款,再更新售后工单状态。订单本已退款时勾选框呈现为不可改的已选状态,且不提交该标记。 顺序不能反:OrderRepository::update_status 的退款闸门要求售后工单仍为待审核/处理中,一旦 set_status 先执行就不再放行。因此退款失败时整次操作中止,避免出现「工单已关、订单未退」的半完成状态。订单号由服务端按 after_sale_id 反查,不经客户端传递。 AfterSaleService::get() 由 private 改为 public 以支撑该反查。dev harness 扩展为可驱动 handler 的六个场景,覆盖勾选/未勾选、重复退款、非已支付订单、已关闭工单与非法状态。
230 lines
16 KiB
JavaScript
230 lines
16 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; 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/)
|
||
}
|
||
})
|