并非每次售后都真的退款——换货、补偿、仅作记录的情况都会把工单标成已完成, 此时退款金额要么是 0 要么没有实际意义,摆在前台只会让人误解钱已经退回去了。 两个前台入口都撤掉该字段: - 售后详细弹窗:明细行只留「申请时间」,处理记录时间线不受影响 - 订单详情弹窗的售后记录卡片:撤掉退款金额后 meta 的 dl 会空壳,连同 dl 一起移除;卡片仍保留状态徽章 + 申请时间 + 原因 + 「查看详细」 连带清理死代码:aftersaleAmountText / afterSaleAmount 两个格式化助手各自 只剩这一处调用,orders.css 里 5 条 .aksrt-order-detail__after-sale-meta 规则也随之无人引用。后台售后页与订单明细页的退款金额列不动——那边是运营 核对账目的口径,需要看到金额。 验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;抽取 buildDetailHtml 跑样例载荷,确认退款金额不再出现、申请时间 / 处理记录 / 再次申请售后按钮都还在。未做浏览器视觉验收。
575 lines
37 KiB
JavaScript
575 lines
37 KiB
JavaScript
'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'))
|
||
})
|