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
渲染校验「处理备注」宽行位于「申请说明」之前;未做浏览器视觉验收。
This commit is contained in:
@@ -240,6 +240,8 @@ test('order view model exposes the after-sale button matrix', () => {
|
||||
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'\]\);/)
|
||||
@@ -452,3 +454,94 @@ test('after-sale assets, template buttons and runtime data are wired', () => {
|
||||
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'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user