Files
wordpress-theme-aksrt/tests/widget-system/after-sale-policy-contract.test.cjs
T
kate522 26ce9a5904 feat(after-sale): 售后处理记录改为每次提交留痕并新增时间线
工单行内新增 handling_log 列,用户申请、用户撤销与后台每次改状态都落一条
「状态 + 备注 + 来源 + 时间」,按时间正序存放、上限 50 条(对齐
MetaAssetService::MAX_RECORDS)。set_status 因此变成读-改-写,补上
GET_LOCK/RELEASE_LOCK 包裹,并在锁内重读工单快照——锁外取快照仍会被并发
追加覆盖。

三处界面由「只显示最后一条」改为回放整条处理链路:

- 后台订单明细「售后记录」按工单渲染头部(状态/金额/申请时间)+ 原因 +
  完整处理记录
- 售后面板手风琴「处理备注」单独占整行并置于「申请说明」上方,删掉已无
  意义的「审核时间」
- 前台售后详细弹窗与订单详情弹窗的售后记录改为时间线:左列绳结状态条
  (节点 + 状态 + 时间),右列处理备注;样式只在 modal-runtime.css 定义
  一份,两个弹窗共用

清理随之失效的死代码:工单的 admin_note / reviewed_at / completed_at,以及
全仓库无写入方的 gateway_request_id / gateway_refund_id / gateway_result;
连带删掉 gatewayResultText()、uninstall 里从未存在过的 aksrt_after_sale
表项,并把两处后台页面重复的状态标签数组收成 after_sale_status_labels()、
重复的 .aksrt-as-log 样式收成 after_sale_log_css()。

顺带修复 88669f69 重构退款流水线时误删 refund_amount()、却仍在 create()
里调用的问题——此前任何用户提交售后都会直接 fatal。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;新增
after-sale-log-harness.php 驱动真实 AfterSaleService,覆盖首条日志、空备注
也留痕、撤销记录、50 条上限与畸形行规范化;两个后台页面经 dev harness
渲染校验「处理备注」宽行位于「申请说明」之前;未做浏览器视觉验收。
2026-10-09 13:59:50 +08:00

548 lines
35 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.
'use strict'
/**
* 「售后策略」功能契约:
* 1) 后台 schema(PHP SchemaPayment after_sale 分区)与 SPA JS schema 的键、顺序、
* 类型、默认值 1:1 对齐,且 schema 与侧边页均已注册(/payment/after-sale 可达);
* 2) 运行时(PaymentRuntime)缺键回退 schema 默认、HTML 走 kses、时效边界正确;
* 3) 售后工单:images 列、create 图片清洗与上限、上传路由权限、latest_for_order;
* 4) 订单视图模型输出 after_sale 状态(按钮矩阵的唯一数据源);
* 5) 前台:申请/超时效/售后详细三个独立弹窗、modal-mini + 蓝色炫彩头、placeholder 与
* 图片上传配置化、不可再申请提示原文、订单详情弹窗不再内嵌售后表单(死代码清理)。
*/
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const test = require('node:test')
const root = path.resolve(__dirname, '..', '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const FIELD_ORDER = [
'after_sale_s',
'after_sale_time_limit',
'after_sale_expired_text',
'after_sale_reasons',
'after_sale_desc',
'after_sale_materials',
'after_sale_reason_placeholder',
'after_sale_img_s',
]
/** Extract the balanced [...] block assigned to a PHP array key inside a section body. */
function phpSection(src, sectionKey) {
const start = src.indexOf(`'${sectionKey}' => [`)
assert.notEqual(start, -1, `PHP schema has a ${sectionKey} section`)
const open = src.indexOf('[', start)
let depth = 0
for (let i = open; i < src.length; i++) {
const ch = src[i]
if (ch === "'" || ch === '"') {
const quote = ch
i++
while (i < src.length) {
if (src[i] === '\\') { i += 2; continue }
if (src[i] === quote) break
i++
}
continue
}
if (ch === '[') depth++
else if (ch === ']') {
depth--
if (depth === 0) return src.slice(open, i + 1)
}
}
throw new Error(`unbalanced PHP section: ${sectionKey}`)
}
test('after-sale schema is 1:1 between PHP and SPA', () => {
const php = phpSection(read('src/Admin/Schema/SchemaPayment.php'), 'after_sale')
const js = read('assets/admin/src/schema/sections/payment/afterSale.js')
assert.match(php, /'key'\s*=>\s*'after_sale'/)
assert.match(php, /'title'\s*=>\s*'售后策略'/)
assert.match(php, /'option_key'\s*=>\s*'aksrt_settings'/)
// 字段键、顺序、类型一致(JS 侧 defaults 与 groups 同序,按首次出现去重)
const phpKeys = [...php.matchAll(/'key'\s*=>\s*'([a-z0-9_]+)'/g)].map((m) => m[1])
const jsKeys = [...js.matchAll(/key:\s*'([a-z0-9_]+)'/g)].map((m) => m[1])
const sectionKeys = (keys) => [...new Set(keys.filter((key) => FIELD_ORDER.includes(key)))]
assert.deepEqual(sectionKeys(phpKeys), FIELD_ORDER)
assert.deepEqual(sectionKeys(jsKeys), FIELD_ORDER)
for (const [phpType, jsType] of [
['switch', 'switch'],
['number', 'number'],
['textarea', 'textarea'],
['group', 'group'],
['input', 'input'],
]) {
assert.ok(php.includes(`'type' => '${phpType}'`), `missing PHP field type ${phpType}`)
assert.ok(js.includes(`type: '${jsType}'`), `missing JS field type ${jsType}`)
}
// 默认值一致(开关/时效/占位文案/预置原因/所需材料)
assert.match(php, /'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true/)
assert.match(js, /after_sale_s:\s*true/)
assert.match(js, /key: 'after_sale_s', label: '售后功能', type: 'switch', default: true/)
assert.match(php, /'after_sale_time_limit', 'label' => '申请售后时效\(天\)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650/)
assert.match(js, /key: 'after_sale_time_limit', label: '申请售后时效\(天\)', type: 'number', default: 7, min: 0, max: 3650/)
assert.match(php, /'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因'/)
assert.match(js, /key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因'/)
assert.match(php, /'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true/)
assert.match(js, /key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true/)
for (const reason of ['质量问题', '假冒品牌', '其他原因']) {
assert.ok(php.includes(`'t' => '${reason}'`), `missing PHP preset reason ${reason}`)
assert.ok(js.includes(`{ t: '${reason}' }`), `missing JS preset reason ${reason}`)
}
for (const material of ['联系邮箱', '联系电话']) {
assert.ok(php.includes(`'t' => '${material}'`), `missing PHP material ${material}`)
assert.ok(js.includes(`{ t: '${material}' }`), `missing JS material ${material}`)
}
// HTML 配置项(超时效提示 / 申请说明)走 kses 通道
assert.match(php, /'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html'/)
assert.match(php, /'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html'/)
assert.match(js, /key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html'/)
assert.match(js, /key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html'/)
})
test('after-sale schema and sidebar page are registered under 支付&付费', () => {
const schemaIndex = read('assets/admin/src/schema/sections/payment/index.js')
assert.match(schemaIndex, /import \{ afterSaleSchema \} from '\.\/afterSale\.js'/)
assert.match(schemaIndex, /after_sale:\s*afterSaleSchema/)
const section = read('assets/admin/src/constants/sections/payment.js')
assert.match(section, /key: 'payment-after-sale'/)
assert.match(section, /name: 'PaymentAfterSale'/)
assert.match(section, /label: '售后策略'/)
assert.match(section, /route: '\/payment\/after-sale'/)
assert.match(section, /canonicalDomain: 'payment'/)
assert.match(section, /section: 'after_sale'/)
// 侧栏条目位于提现策略之后(支付&付费末位)
assert.ok(section.indexOf("key: 'payment-after-sale'") > section.indexOf("key: 'payment-withdrawal'"))
const schemaRegistry = read('assets/admin/src/schema/index.js')
assert.match(schemaRegistry, /paymentSchemas/)
})
test('after-sale runtime resolves defaults, overrides and the expiry boundary matrix', () => {
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-policy-harness.php')], {
cwd: root,
encoding: 'utf8',
})
assert.equal(result.status, 0, result.stderr || result.stdout)
const payload = JSON.parse(result.stdout)
assert.deepEqual(payload.defaults, {
enabled: true,
time_limit: 7,
expired_text: '<div>抱歉,您的订单已超过售后申请时效</div>',
reasons: [
{ slug: 'c440cdf71825', name: '质量问题' },
{ slug: '2ead35a456a0', name: '假冒品牌' },
{ slug: 'fba26eab6b2b', name: '其他原因' },
],
materials: ['联系邮箱', '联系电话'],
desc: '',
img_enabled: true,
reason_placeholder: '必填,请您详细填写申请原因',
})
assert.equal(payload.overridden.enabled, false)
assert.equal(payload.overridden.time_limit, 30)
assert.equal(payload.overridden.img_enabled, false)
assert.equal(payload.overridden.reason_placeholder, '请描述问题')
assert.deepEqual(payload.overridden.reasons, [
{ slug: 'c440cdf71825', name: '质量问题' },
{ slug: '327e0ab68567', name: '发错货' },
])
assert.deepEqual(payload.overridden.materials, ['联系邮箱'])
// HTML 配置项剥离 script,保留 div
assert.equal(payload.overridden.expired_text, '<div>已超过时效</div>')
assert.equal(payload.overridden.desc, '<div>请保持商品完好</div>')
assert.deepEqual(payload.expired_ok, {
within: true,
exact_boundary: true,
just_expired: true,
long_expired: true,
no_limit: true,
missing_paid: true,
})
})
test('after-sale tickets persist evidence images and expose the latest row', () => {
const schema = read('src/Core/DBMigratorPart2.php')
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
// images 列随 schema 2.5.1 引入:dbDelta 只在版本变化时重跑,列变更必须同步升版本,
// 否则老安装不会 ALTER TABLE,写入直接报 Unknown column 'images'。
const migrator = read('src/Core/DBMigrator.php')
const version = migrator.match(/const SCHEMA_VERSION = '(\d+)\.(\d+)\.(\d+)'/)
assert.ok(version, 'DBMigrator exposes a numeric SCHEMA_VERSION')
const [major, minor, patch] = version.slice(1).map(Number)
assert.ok(
major > 2 || (major === 2 && minor > 5) || (major === 2 && minor === 5 && patch >= 1),
`SCHEMA_VERSION must be at least 2.5.1 after adding order_aftersale.images, got ${major}.${minor}.${patch}`
)
const service = read('src/Payment/AfterSaleService.php')
assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\], array \$apply_form = \[\]\)/)
// 结构化申请表单:预置原因 / 申请资料 / 申请说明独立入库(reason 融合串保持原样)
assert.match(service, /\$apply_form = \$this->sanitize_apply_form\(\$apply_form\);/)
assert.match(service, /private function sanitize_apply_form\(array \$form\): array/)
assert.match(service, /'apply_form' => \$apply_form === \[\] \? null : wp_json_encode\(\$apply_form/)
assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/)
assert.match(service, /'images' => wp_json_encode\(\$images\)/)
assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/)
assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/)
// normalize 输出 images 数组(坏 JSON / 空串回落空数组)
assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/)
// 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传
assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/)
assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/)
assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/)
assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/)
assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/)
assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/)
// 提交 REST 透传 images
// rest_submit 同步上报结构化申请表单(reason_label / materials / description)
assert.match(service, /'reason_label' => \(string\) \$request->get_param\('reason_label'\),/)
assert.match(service, /'materials' +\=> is_array\(\$materials\) \? \$materials : \[\],/)
assert.match(service, /'description' +\=> \(string\) \$request->get_param\('description'\),/)
// 既有售后资产契约(锁 / 退款 / 完成)不受影响
assert.match(service, /GET_LOCK\(%s, 5\)/)
assert.match(service, /Order::STATUS_PAID/)
})
test('order view model exposes the after-sale button matrix', () => {
const userOrders = read('src/Payment/UserOrders.php')
assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/)
assert.match(userOrders, /'after_sale' => \$after_sale_state,/)
assert.match(userOrders, /'enabled'\s*=> \$enabled,/)
assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/)
assert.match(userOrders, /'has_application' => \$has_application,/)
assert.match(userOrders, /'open'\s*=> \$open,/)
assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/)
// 可再次申请售后按工单状态推理:rejected(被拒绝)/ cancelled(已取消)允许再次申请;
// pending / processing / approved(流转中)与 completed(已完结)不允许
assert.match(userOrders, /\$reapplyable_statuses = \[AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED\];/)
assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && \$has_application && in_array\(\$latest_status, \$reapplyable_statuses, true\),/)
assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/)
assert.match(userOrders, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/)
// 处理记录取代 reviewed_at / completed_at / admin_note 三个单点字段
assert.match(userOrders, /'handling_log'\s*=> array_values\(\(array\) \(\$latest\['handling_log'\] \?\? \[\]\)\),/)
// 仅已支付订单开放;开关关闭整体不处理售后
assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/)
// 时效以支付时间为准
assert.match(userOrders, /\$this->after_sale_state\(\$group, \$order_number, \(string\) \(\$order\['paid_at'\] \?\? ''\), \$after_sale_service, \$after_sales\)/)
// 列表模式单行查询,详情模式复用全量工单
assert.match(userOrders, /\$after_sales = \$detail && \$after_sale_service \? \$after_sale_service->list_for_order\(\$order_number\) : \[\];/)
assert.match(userOrders, /\$latest = \$service->latest_for_order\(\$order_number\);/)
assert.match(userOrders, /\$view\['after_sales'\] = \$after_sales;/)
// 旧 can_after_sale 双路已移除
assert.ok(!userOrders.includes('can_after_sale'), 'can_after_sale must be replaced by the after_sale state')
})
test('frontend after-sale modals are standalone and config-driven', () => {
const js = read('assets/js/order-after-sale.js')
// 四个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区
assert.match(js, /className: 'aksrt-after-sale-apply-modal'/)
assert.match(js, /className: 'aksrt-after-sale-detail-modal'/)
assert.match(js, /className: 'aksrt-after-sale-expired-modal'/)
assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/)
assert.match(js, /modal-colorful-header colorful-bg jb-blue/)
assert.match(js, /AksrtModalRuntime\.open\(\{/)
// 滚动容器与禁封申诉弹窗同口径:no-scrollbar 完全隐藏滚动条(含细滚动条),
// 避免 modal-mini 弹窗边缘出现可见滚动栏。
assert.match(js, /'<div class="scroll-y no-scrollbar max-vh5">'/)
assert.match(js, /'<div class="scroll-y no-scrollbar max-vh5 aksrt-after-sale-detail__body">'/)
assert.ok(!js.includes('mini-scrollbar'), 'after-sale modals must not render a visible scrollbar')
// 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置)
assert.match(js, /reasonOptions\(\)/)
assert.match(js, /materialOptions\(\)/)
assert.match(js, /data-aksrt-after-sale-reason=/)
assert.match(js, /data-aksrt-after-sale-material=/)
assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/)
assert.match(js, /name="reason"/)
assert.match(js, /notyf\('请填写申请说明', 'danger'\)/)
assert.match(js, /notyf\('请选择售后原因', 'danger'\)/)
// 图片上传:开关控制渲染,最多 3 张,上传/删除/提交
assert.match(js, /if \(cfg\.imgEnabled\) \{/)
assert.match(js, /MAX_IMAGES = 3/)
assert.match(js, /\/after-sale\/upload-image'/)
assert.match(js, /data-aksrt-after-sale-image-remove/)
assert.match(js, /images: collectImages\(shell\)/)
assert.match(js, /reason_label: selectedReasonLabel\(shell\)/)
assert.match(js, /materials: collectMaterials\(shell\)/)
assert.match(js, /description: reasonText,/)
// 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出
assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/)
assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/)
// 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了
// 申请表单分行展示:原因 / 申请资料 / 申请说明各自独立成行(与申请时间同格式);
// 老数据无 apply_form 时兜底展示融合串
assert.match(js, /detailRow\('原因', String\(applyForm\.reason_label \|\| ''\)\)/)
assert.match(js, /detailRow\('申请说明', String\(applyForm\.description \|\| ''\)\)/)
assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/)
// 不可再申请:不再展示阻断文案,仅保留关闭入口;可再申请:仅保留单个主按钮(无「关闭」)
assert.ok(!js.includes('暂不支持再次申请售后'), 'blocked notice copy must be removed')
assert.ok(!js.includes('REAPPLY_BLOCKED_TEXT'), 'blocked text constant must be removed')
assert.ok(!js.includes('>关闭</button>'), 'detail modal must not render a 关闭 button')
assert.match(js, /data-aksrt-modal-close>我知道了</)
assert.match(js, /data-aksrt-after-sale-reapply data-aksrt-after-sale-payload="/)
// 再次申请售后:can_reapply 必然伴随 has_application=true,入口点击须清除标记,
// 否则 openApplyFlow 的 has_application 分流会把用户弹回「售后详细」打转
assert.match(js, /payload\.after_sale\.has_application = false[\s\S]{0,120}openApplyFlow\(payload\)/)
// footer 平铺按钮:相邻按钮分界线(legacy .but + .but 分隔在家族内还原,四个售后弹窗同款)
// + 悬停反馈(参考 Element 卡片 footer:整块区域浅灰底 + 文字转主色,主按钮 hover 略加深)
const modalCss = read('assets/css/frontend/modal-runtime.css')
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts\s*\{[^}]*padding:\s*0;/)
// footer 不留垂直内边距:上下 12px 并入按钮(min-height 62 = 12+38+12),按钮盒=整个底部区域
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but,[\s\S]{0,260}min-height:\s*62px/)
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--modal-line-soft/)
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/)
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/)
// 撤销售后:入口仅随待审核状态渲染,点击先过二次确认弹窗,确认后 DELETE 工单
assert.match(js, /status === 'pending'[\s\S]*?data-aksrt-after-sale-withdraw="' \+ escapeHtml\(\(order && order\.order_number\) \|\| ''\)/)
assert.match(js, /buildWithdrawHtml\(orderNumber\)/)
assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/)
assert.match(js, /data-aksrt-after-sale-withdraw-confirm="' \+ escapeHtml\(orderNumber\) \+ '"/)
assert.match(js, /method: 'DELETE'/)
assert.match(js, /AksrtModalRuntime\.close\('withdraw'\)/)
assert.match(js, /notyf\('售后申请已撤销', 'success'\)/)
// 人机验证:提交与撤销前先过验证(场景未启用时挂载点缺失,run 直接放行)
assert.match(js, /function runCaptcha\(scene\)/)
assert.match(js, /runCaptcha\('after_sale_apply'\)/)
assert.match(js, /runCaptcha\('after_sale_cancel'\)/)
assert.match(js, /AKSRTCaptcha\.run\(scene\)/)
// 提交后关闭弹窗并刷新订单列表
assert.match(js, /AKSRTOrderCenter\.refresh\(orderNumber\)/)
assert.match(js, /window\.AksrtModalRuntime\.close\('submit'\)/)
// 入口委托:申请 / 售后详细
assert.match(js, /data-aksrt-after-sale-apply/)
assert.match(js, /data-aksrt-after-sale-detail/)
assert.match(js, /auto_fun\.aksrt-after-sale/)
})
test('order center drops the embedded after-sale form and exposes refresh()', () => {
const js = read('assets/js/order-center.js')
// 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理)
assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed')
assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed')
assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed')
// 列表卡片与订单详情弹窗底部共用新的售后按钮
assert.match(js, /function afterSaleButtons\(order\)/)
assert.match(js, /function afterSalePayload\(order\)/)
assert.match(js, /data-aksrt-after-sale-detail="' \+ payload \+ '"/)
assert.match(js, /data-aksrt-after-sale-apply="' \+ payload \+ '"/)
assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\);/)
assert.ok(!js.includes('\n html += afterSaleButtons(order);\n'), 'detail modal footer must not render the shared after-sale buttons')
// 底部按钮与禁封申诉弹窗同式(but-average modal-buts);「售后详细」入口收拢到记录卡片
assert.match(js, /aksrt-order-detail__actions but-average modal-buts/)
assert.match(js, /data-aksrt-after-sale-apply="' \+ afterSalePayload\(order\) \+ '"/)
// 底部「关闭」与售后详细「我知道了」同款(but c-blue,平铺观感由 orders.css 呈现)
assert.match(js, /class="but c-blue" data-aksrt-modal-close>关闭</)
assert.ok(js.includes('查看详细</button>'), 'record card has a view-detail entry')
assert.match(js, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/)
// 记录卡片:原因 / 申请理由分行(新数据走 apply_form,老数据兜底融合串)
assert.ok(js.includes('aksrt-order-detail__after-sale-field">原因:'), 'record card shows 原因:')
assert.ok(js.includes('aksrt-order-detail__after-sale-field">申请理由:'), 'record card shows 申请理由:')
assert.match(js, /data-aksrt-after-sale-detail="/)
assert.match(js, /data-aksrt-after-sale-apply="/)
// 订单详情弹窗 CSS:记录卡片「查看详细」foot 右对齐;底部按钮与售后弹窗家族
// 同款平铺观感(modal-runtime 同款规则镜像到吸底 footer),单按钮占满整行。
const ordersCss = read('assets/css/routes/orders.css')
assert.match(ordersCss, /\.aksrt-order-detail__after-sale-foot\s*\{[^}]*justify-content:\s*flex-end/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*background:\s*transparent/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue\s*\{[^}]*color:\s*var\(--modal-primary/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:only-child\s*\{[^}]*grid-column:\s*1 \/ -1/)
// 分界线与悬停阴影与售后弹窗家族同款(分隔色用本弹窗 footer 同源的 --detail-line);
// footer 零垂直内边距、按钮满高(min-height 64 = 13+38+13),按钮盒=整个底部区域,安全区留 footer 底部
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\s*\{[^}]*padding:\s*0 0 env\(safe-area-inset-bottom/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*min-height:\s*64px/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--detail-line\)/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/)
// 售后记录区保留在订单详情弹窗:记录卡片带申请时间,「查看详细」逐条入口;
// 取消申请按钮与处理器已移除(撤销入口移入售后详细弹窗)。
assert.match(js, /售后记录/)
assert.match(js, /<time>" \+ escapeHtml\(item\.created_at\)/)
assert.ok(!js.includes('data-aksrt-after-sale-cancel'), 'cancel entry moved into the after-sale detail modal')
assert.ok(!js.includes('取消申请'), 'order detail must not render the legacy cancel button')
// refresh 供售后弹窗提交后刷新列表与统计
assert.match(js, /function refresh\(orderNumber\) \{/)
assert.match(js, /window\.AKSRTOrderCenter = \{ init: init, destroy: destroy, refresh: refresh \}/)
assert.match(js, /liveStates\.forEach\(function \(state\) \{/)
})
test('after-sale assets, template buttons and runtime data are wired', () => {
const scripts = read('src/Core/ScriptsPart1b.php')
assert.match(scripts, /'aksrt-order-after-sale'/)
assert.match(scripts, /\/assets\/js\/order-after-sale\.js'/)
const scripts1c = read('src/Core/ScriptsPart1c.php')
assert.match(scripts1c, /'afterSale'\s*=> \(function \(\) \{/)
assert.match(scripts1c, /PaymentRuntime::get_after_sale_settings\(\)/)
assert.match(scripts1c, /'maxImages'\s*=> 3/)
const template = read('inc/helpers/user-center-renderers/profile-orders.php')
assert.match(template, /\$after_sale = is_array\(\$order\['after_sale'\] \?\? null\) \? \$order\['after_sale'\] : \[\];/)
// 载荷随按钮渲染(order-after_sale.js 同步消费),点击即弹窗、无前置请求
assert.match(template, /\$after_sale_payload = wp_json_encode\(\[/)
assert.match(template, /'after_sale'\s*=> \$after_sale,/)
assert.match(template, /data-aksrt-after-sale-detail="<\?php echo esc_attr\(\$after_sale_payload\);/)
assert.match(template, /data-aksrt-after-sale-apply="<\?php echo esc_attr\(\$after_sale_payload\);/)
assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/)
assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag')
// 售后人机验证挂载点(申请 / 撤销):场景未启用时 aksrt_render_captcha 无输出
assert.match(template, /function_exists\('aksrt_render_captcha'\)/)
assert.match(template, /aksrt_render_captcha\('after_sale_apply'\)/)
assert.match(template, /aksrt_render_captcha\('after_sale_cancel'\)/)
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-after-sale-apply-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-detail-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-expired-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-withdraw-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /width: min\(400px, calc\(100vw - 32px\)\)/)
assert.match(css, /\.aksrt-after-sale-apply-modal \.modal-colorful-header/)
assert.match(css, /\.aksrt-after-sale-apply-modal \.colorful-bg > \.colorful-make/)
// 弹窗底部按钮:四个售后弹窗统一全宽平铺贴底,子按钮去独立胶囊观感(覆盖全局按钮网格)
for (const modal of ['apply', 'detail', 'expired', 'withdraw']) {
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts[\\s\\S]*?margin: 16px -18px -16px`))
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts > \\.but[\\s\\S]*?border-radius: 0`))
}
assert.match(css, /\.but:only-child \{ grid-column: 1 \/ -1; \}/)
// 撤销售后入口:待审核状态行内的文字式入口按钮
assert.match(css, /\.aksrt-after-sale-detail__statusCell/)
assert.match(css, /\.aksrt-after-sale-withdraw \{[^}]*background: transparent/)
const field = read('assets/admin/src/components/form/AksField.vue')
assert.match(field, /import draggable from 'vuedraggable'/)
assert.match(field, /v-model="repeaterDragList"/)
assert.match(field, /handle="\.aksrt-field__repeater-handle"/)
assert.match(field, /function repeaterDragKey\(item\)/)
})
test('every after-sale submission is logged with a status, note, source and time', () => {
const service = read('src/Payment/AfterSaleService.php')
// 记录落在工单行内,按时间正序追加,超过上限只保留最近的一段
assert.match(service, /private const MAX_LOG_ENTRIES = 50;/)
assert.match(service, /private function append_log\(int \$id, array \$current, string \$status, string \$note, string \$source\): void \{/)
assert.match(service, /array_slice\(\$log, -self::MAX_LOG_ENTRIES\)/)
assert.match(service, /JSON_UNESCAPED_UNICODE \| JSON_UNESCAPED_SLASHES/)
assert.match(service, /'handling_log' => \$this->encode_log\(\[\$this->log_entry\(self::STATUS_PENDING, '', self::SOURCE_USER, \$now\)\]\),/)
assert.match(service, /public const SOURCE_ADMIN = 'admin';/)
assert.match(service, /public const SOURCE_USER = 'user';/)
// 三条写入路径都必须留痕:用户申请 / 用户撤销 / 后台改状态
assert.match(service, /create\(int \$user_id[\s\S]*?'handling_log'/)
assert.match(service, /cancel_for_user\([\s\S]*?append_log\(\(int\) \$row\['id'\], \$row, self::STATUS_CANCELLED, '', self::SOURCE_USER\)/)
assert.match(service, /set_status\([\s\S]*?append_log\(\$id, \$row, \$status, \$note, self::SOURCE_ADMIN\)/)
// set_status 现在是「读-改-写」JSON,必须与 create/cancel 共用订单锁
assert.match(service, /set_status\(int \$id, string \$status, string \$note = ''\)[\s\S]*?GET_LOCK\(%s, 5\)[\s\S]*?RELEASE_LOCK\(%s\)/)
// 单点备注 / 单点时间戳 / 从未写入的渠道字段全部移除
for (const dead of ['admin_note', 'reviewed_at', 'completed_at', 'gateway_result', 'gateway_request_id', 'gateway_refund_id']) {
assert.ok(!service.includes(dead), `${dead} must be removed from AfterSaleService`)
}
// 规范化:畸形条目丢弃,未知 source 归 admin,非数组整体视为空
assert.match(service, /private function normalize_log\(\$log\): array \{/)
assert.match(service, /\$row\['handling_log'\] = \$this->normalize_log\(json_decode\(\(string\) \(\$row\['handling_log'\] \?\? ''\), true\)\);/)
// 退款金额工具在 create() 里被调用,不能再次缺失(88669f69 曾误删导致 fatal)
assert.match(service, /private function refund_amount\(array \$order\): \?string \{/)
})
test('handling-log behaviour is covered against the real service', () => {
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-log-harness.php')], {
cwd: root,
encoding: 'utf8',
})
assert.equal(result.status, 0, result.stderr || result.stdout)
const payload = JSON.parse(result.stdout)
assert.equal(payload.failed, 0, JSON.stringify(payload.results))
const names = payload.results.map((entry) => entry.name)
for (const name of [
'create_seeds_single_user_entry',
'status_changes_append_even_without_note',
'cancel_appends_user_entry',
'log_is_capped_at_50',
'normalize_drops_malformed_entries',
]) {
assert.ok(names.includes(name), `missing handling-log scenario: ${name}`)
}
})
test('frontend renders the handling log as a rope-knot timeline', () => {
const js = read('assets/js/order-after-sale.js')
const center = read('assets/js/order-center.js')
// 售后详细弹窗:处理记录时间线取代单行的「处理备注 / 完成时间」
assert.ok(!js.includes("detailRow('处理备注'"), 'single admin-note row must be removed')
assert.ok(!js.includes("detailRow('完成时间'"), 'single completed-at row must be removed')
assert.match(js, /function handlingEntryTitle\(entry\)/)
assert.match(js, /function handlingLogHtml\(log\)/)
assert.match(js, /handlingLogHtml\(latest\.handling_log\)/)
assert.match(js, /status === 'cancelled' \? '用户撤销售后' : '用户提交申请'/)
assert.match(js, /'<ol class="aksrt-after-sale-timeline">'/)
assert.match(js, /aksrt-after-sale-timeline__knot/)
assert.match(js, /aksrt-after-sale-timeline__status/)
assert.match(js, /aksrt-after-sale-timeline__time/)
assert.match(js, /aksrt-after-sale-timeline__note/)
// 订单详情弹窗:每条工单同样渲染时间线,载荷带上 handling_log
assert.ok(!center.includes('gatewayResultText'), 'dead gateway-result renderer must be removed')
assert.ok(!center.includes('<dt>渠道结果</dt>'), 'dead 渠道结果 row must be removed')
assert.ok(!center.includes('<dt>处理备注</dt>'), 'single 处理备注 row must be removed')
assert.match(center, /function orderDetailHandlingLogHtml\(log\)/)
assert.match(center, /orderDetailHandlingLogHtml\(item\.handling_log\)/)
assert.match(center, /handling_log: Array\.isArray\(item\.handling_log\) \? item\.handling_log : \[\],/)
// 时间线样式:竖线穿节点、末项不收口,左列固定宽度、右列自适应
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-after-sale-timeline__item \{ --as-accent: var\(--modal-muted\); display: grid; grid-template-columns: 88px minmax\(0, 1fr\)/)
assert.match(css, /\.aksrt-after-sale-timeline__aside::before \{ content: ""; position: absolute; top: 12px; bottom: -2px/)
assert.match(css, /\.aksrt-after-sale-timeline__item:last-child \.aksrt-after-sale-timeline__aside::before \{ display: none; \}/)
assert.match(css, /\.aksrt-after-sale-timeline__knot/)
assert.match(css, /\.aksrt-after-sale-timeline__note/)
// 打包产物同步:public.css / public.min.css 都含时间线规则
assert.ok(read('assets/css/build/public.css').includes('.aksrt-after-sale-timeline__knot'))
assert.ok(read('assets/css/build/public.min.css').includes('.aksrt-after-sale-timeline__knot'))
})