Files
wordpress-theme-aksrt/tests/widget-system/after-sale-policy-contract.test.cjs
T
kate522 16b57dcf1c fix(after-sale): 前台售后不再展示「退款金额」字段
并非每次售后都真的退款——换货、补偿、仅作记录的情况都会把工单标成已完成,
此时退款金额要么是 0 要么没有实际意义,摆在前台只会让人误解钱已经退回去了。
两个前台入口都撤掉该字段:

- 售后详细弹窗:明细行只留「申请时间」,处理记录时间线不受影响
- 订单详情弹窗的售后记录卡片:撤掉退款金额后 meta 的 dl 会空壳,连同
  dl 一起移除;卡片仍保留状态徽章 + 申请时间 + 原因 + 「查看详细」

连带清理死代码:aftersaleAmountText / afterSaleAmount 两个格式化助手各自
只剩这一处调用,orders.css 里 5 条 .aksrt-order-detail__after-sale-meta
规则也随之无人引用。后台售后页与订单明细页的退款金额列不动——那边是运营
核对账目的口径,需要看到金额。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;抽取
buildDetailHtml 跑样例载荷,确认退款金额不再出现、申请时间 / 处理记录 /
再次申请售后按钮都还在。未做浏览器视觉验收。
2026-10-10 13:04:37 +08:00

575 lines
37 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,/)
// can_apply 只问「有没有进行中的工单」:completed 不挡,能否再售后由订单是否已退款决定
assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/)
// 可再次申请售后按工单状态推理:rejected / cancelled / completed 都允许再次申请;
// pending / processing / approved(流转中)不允许。订单已退款时 enabled 直接为 false,
// 上面那条自然挡死,不需要工单状态再判一次。
assert.match(userOrders, /\$reapplyable_statuses = \[\s*AfterSaleService::STATUS_REJECTED,\s*AfterSaleService::STATUS_CANCELLED,\s*AfterSaleService::STATUS_COMPLETED,\s*\];/)
assert.ok(!userOrders.includes('$reapplyable ='), 'can_apply must not re-check the ticket status')
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',
'settled_ticket_still_allows_reapply',
'open_ticket_blocks_duplicate_apply',
]) {
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/)
// [节点][处理状态][时间] 同一行:节点独占一列,状态与时间包在 head 里
assert.match(js, /'<span class="aksrt-after-sale-timeline__knot"><\/span>'/)
assert.match(js, /'<div class="aksrt-after-sale-timeline__head">' \+/)
assert.match(js, /'<div class="aksrt-after-sale-timeline__body">';/)
// 退款金额不展示:并非每次售后都真的退款,工单被标完成也不代表把钱退回去了
assert.ok(!js.includes("detailRow('退款金额'"), 'after-sale detail modal must not show 退款金额')
assert.ok(!js.includes('aftersaleAmountText'), 'dead aftersaleAmountText must be removed')
assert.match(js, /detailRow\('申请时间', String\(latest\.created_at \|\| ''\)\)/)
// 订单详情弹窗:不再渲染售后状态节点进程与退款金额,只保留状态徽章 + 原因 + 「查看详细」;
// 完整处理记录由点开的售后详细弹窗呈现,载荷仍带上 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.ok(!center.includes('<dt>退款金额</dt>'), 'order detail card must not show 退款金额')
assert.ok(!center.includes('afterSaleAmount'), 'dead afterSaleAmount must be removed')
assert.ok(!center.includes('aksrt-order-detail__after-sale-meta'), 'empty meta dl must be removed')
assert.ok(!center.includes('orderDetailHandlingLogHtml'), 'order detail modal must not render the timeline')
assert.ok(!center.includes('AFTER_SALE_STATUS_LABELS'), 'order detail modal must not carry timeline labels')
assert.ok(!center.includes('handlingEntryTitle'), 'order detail modal must not carry the timeline title helper')
assert.ok(!center.includes('aksrt-after-sale-timeline'), 'order detail modal must not emit timeline markup')
assert.match(center, /handling_log: Array\.isArray\(item\.handling_log\) \? item\.handling_log : \[\],/)
// 「查看详细」入口保留:处理记录从这里点开
assert.match(center, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/)
// 时间线样式:竖线穿节点、末项不收口,左列固定宽度、右列自适应
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-after-sale-timeline__item \{ --as-accent: var\(--modal-muted\); position: relative; display: grid; grid-template-columns: 10px minmax\(0, 1fr\); gap: 10px; padding: 0 0 14px; \}/)
assert.match(css, /\.aksrt-after-sale-timeline__item::before \{ content: ""; position: absolute; top: 14px; bottom: -2px; left: 4px/)
assert.match(css, /\.aksrt-after-sale-timeline__item:last-child::before \{ display: none; \}/)
assert.match(css, /\.aksrt-after-sale-timeline__head \{ display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; \}/)
assert.match(css, /\.aksrt-after-sale-timeline__knot/)
assert.match(css, /\.aksrt-after-sale-timeline__note/)
const ordersCss = read('assets/css/routes/orders.css')
assert.ok(!ordersCss.includes('aksrt-order-detail__after-sale-meta'), 'dead after-sale-meta rules must be removed')
// 旧的两列结构(aside / main)已移除
assert.ok(!css.includes('aksrt-after-sale-timeline__aside'), 'left-column aside must be removed')
assert.ok(!css.includes('aksrt-after-sale-timeline__main'), 'right-column main must be removed')
// 打包产物同步: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'))
})