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:
2026-10-09 13:59:50 +08:00
parent 31e6ba00af
commit 26ce9a5904
15 changed files with 644 additions and 57 deletions
+28 -5
View File
@@ -77,11 +77,16 @@ namespace AKSRT\Payment {
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
public const SOURCE_ADMIN = 'admin';
public const SOURCE_USER = 'user';
private static $i;
/** @var array<int,array<string,mixed>> keyed by after-sale id */
public static array $rows = [];
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function admin_list(array $args = []): array { return array_values(self::$rows); }
public function list_for_order(string $order_number): array {
return array_values(array_filter(self::$rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $order_number));
}
public function count(array $args = []): int { return count(self::$rows); }
public function get(int $id): ?array { return self::$rows[$id] ?? null; }
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
@@ -109,8 +114,7 @@ namespace AKSRT\Payment {
}
if (!isset(self::$rows[$id])) return new \WP_Error('aksrt_after_sale_state', '售后申请不存在', ['status' => 409]);
self::$rows[$id]['status'] = $status;
if ($note !== '') self::$rows[$id]['admin_note'] = $note;
if ($status !== self::STATUS_PENDING) self::$rows[$id]['reviewed_at'] = current_time('mysql');
self::$rows[$id]['handling_log'][] = ['status' => $status, 'note' => $note, 'source' => 'admin', 'created_at' => current_time('mysql')];
return self::$rows[$id];
}
}
@@ -121,6 +125,7 @@ namespace AKSRT\Payment {
public static array $orders = [];
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function get($n): ?array { return self::$orders[(string) $n] ?? null; }
public function get_admin_order_context(string $order_number): array { return ['customer_note' => '买家备注示例']; }
public function get_orders_admin(array $args = [], string $search = ''): array { return array_values(self::$orders); }
public function get_orders_count(array $args = [], string $search = ''): int { return count(self::$orders); }
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
@@ -207,11 +212,19 @@ require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php'
5 => ['id' => 5, 'order_number' => 'ORD100', 'user_id' => 3, 'reason' => 'legacy-reason',
'apply_form' => ['reason_label' => '商品与描述不符', 'materials' => [['label' => '联系方式', 'value' => 'wx-abc']], 'description' => '说明文字'],
'images' => ['https://example.test/img1.png'], 'refund_amount' => '12.50', 'status' => 'pending',
'admin_note' => '', 'gateway_result' => '', 'created_at' => '2026-10-01 10:00:00', 'reviewed_at' => ''],
'handling_log' => [
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
['status' => 'processing', 'note' => '已联系仓库核实库存', 'source' => 'admin', 'created_at' => '2026-10-01 14:20:00'],
],
'created_at' => '2026-10-01 10:00:00'],
6 => ['id' => 6, 'order_number' => 'ORD101', 'user_id' => 4, 'reason' => 'r2',
'apply_form' => null, 'images' => [], 'refund_amount' => '8.00', 'status' => 'processing',
'admin_note' => '已有备注', 'gateway_result' => json_encode(['status' => 'manual', 'message' => '线下退款']),
'created_at' => '2026-10-02 11:00:00', 'reviewed_at' => '2026-10-03 09:00:00'],
'handling_log' => [
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-02 11:00:00'],
['status' => 'processing', 'note' => '资料已补齐,进入处理', 'source' => 'admin', 'created_at' => '2026-10-02 15:40:00'],
['status' => 'rejected', 'note' => '超出七天无理由范围,已驳回', 'source' => 'admin', 'created_at' => '2026-10-03 09:00:00'],
],
'created_at' => '2026-10-02 11:00:00'],
];
\AKSRT\Payment\OrderRepository::$orders = [
'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => ['product_title' => '测试商品'], 'currency' => 'CNY', 'amount' => '12.50', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-01 09:00:00', 'paid_at' => '2026-10-01 09:05:00'],
@@ -230,6 +243,16 @@ require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php'
exit(0);
}
// 订单明细页:售后记录单元格按工单渲染完整处理记录。
if ($scenario === 'order-detail') {
$_GET = ['page' => 'aksrt_orders', 'order' => 'ORD100'];
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
ob_start();
$pay->render_orders_page();
file_put_contents(__DIR__ . '/order-detail-rendered.html', ob_get_clean());
exit(0);
}
if ($scenario !== '') {
// The handler ends in exit(), so the call log is flushed on shutdown.
register_shutdown_function(static function (): void {
@@ -0,0 +1,224 @@
<?php
/**
* After-sale handling-log harness.
*
* Drives the REAL AfterSaleService against an in-memory $wpdb stand-in so the
* handling-log contract is covered end to end: every state submission appends one
* 「status + note + source + created_at」 entry, the log stays chronological, it is
* capped, and malformed rows normalize instead of fataling.
*
* Usage: php after-sale-log-harness.php -> prints a JSON result object.
*/
namespace {
define('ARRAY_A', 'ARRAY_A');
final class WP_Error {
public function __construct(private string $code, private string $message, private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data(): array { return $this->data; }
}
define('ABSPATH', __DIR__ . '/');
// ---------- WP function stubs ----------
function __($s, $d = null) { return (string) $s; }
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_url_raw($s) { return (string) $s; }
function sanitize_key($s) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $s)) ?? ''; }
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
function sanitize_textarea_field($s) { return trim(strip_tags((string) $s)); }
function wp_json_encode($v, $f = 0) { return (string) json_encode($v, $f); }
function current_user_can($c) { return true; }
// Monotonic clock: every append must land on a strictly later timestamp so the
// chronological ordering assertion is meaningful.
$GLOBALS['aksrt_log_now'] = strtotime('2026-10-01 10:00:00');
function current_time($type = 'mysql', $gmt = false) {
$GLOBALS['aksrt_log_now'] += 1;
return gmdate('Y-m-d H:i:s', $GLOBALS['aksrt_log_now']);
}
// ---------- In-memory $wpdb ----------
final class FakeWpdb {
public string $prefix = 'wp_';
/** @var array<int,array<string,mixed>> keyed by after-sale id */
public array $rows = [];
public int $insert_id = 0;
private int $next_id = 100;
public function prepare(string $sql, ...$args) {
if (count($args) === 1 && is_array($args[0])) $args = $args[0];
$i = 0;
return preg_replace_callback('/%[sd]/', static function ($m) use (&$i, $args): string {
if ($i >= count($args)) return $m[0];
$value = $args[$i++];
return $m[0][1] === 'd' ? (string) (int) $value : "'" . str_replace("'", "''", (string) $value) . "'";
}, $sql) ?? $sql;
}
public function get_var(string $sql) {
if (preg_match("/GET_LOCK\('/", $sql)) return 1;
if (preg_match("/RELEASE_LOCK\('/", $sql)) return 1;
if (str_starts_with($sql, 'SELECT COUNT(*)')) return count($this->rows);
throw new RuntimeException('unsupported get_var: ' . $sql);
}
public function get_row(string $sql, $output = null) {
if (preg_match("/WHERE id = (\d+)$/", $sql, $m)) return $this->rows[(int) $m[1]] ?? null;
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
$statuses = preg_match("/status IN \(([^)]*)\)/", $sql, $s) ? explode(',', str_replace("'", '', $s[1])) : null;
$found = null;
foreach ($this->rows as $row) {
if ((string) ($row['order_number'] ?? '') !== $m[1]) continue;
if ($statuses !== null && !in_array((string) ($row['status'] ?? ''), array_map('trim', $statuses), true)) continue;
if ($found === null || (int) $row['id'] > (int) $found['id']) $found = $row;
}
return $found;
}
throw new RuntimeException('unsupported get_row: ' . $sql);
}
public function get_results(string $sql, $output = null) {
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
$rows = array_values(array_filter($this->rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $m[1]));
usort($rows, static fn($a, $b): int => (int) $b['id'] <=> (int) $a['id']);
return $rows;
}
throw new RuntimeException('unsupported get_results: ' . $sql);
}
public function insert(string $table, array $data, array $formats = []) {
$id = $this->next_id++;
$this->rows[$id] = $data + ['id' => $id];
$this->insert_id = $id;
return 1;
}
public function update(string $table, array $data, array $where, array $formats = [], array $where_formats = []) {
$id = (int) ($where['id'] ?? 0);
if (!isset($this->rows[$id])) return false;
$this->rows[$id] = array_merge($this->rows[$id], $data);
return 1;
}
}
// ---------- Domain stubs the service collaborates with ----------
require __DIR__ . '/../../src/Ledger/AssetType.php';
require __DIR__ . '/../../src/Ledger/AssetAmount.php';
}
namespace AKSRT\Core {
final class DBMigrator {
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
}
}
namespace AKSRT\Payment {
final class Order {
public const STATUS_PAID = 'paid';
}
final class OrderRepository {
private static $i;
/** @var array<string,array<string,mixed>> keyed by order number */
public static array $orders = [];
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function get_user_order(int $user_id, string $order_number): ?array {
return self::$orders[$order_number] ?? null;
}
}
require __DIR__ . '/../../src/Payment/AfterSaleService.php';
}
namespace {
$GLOBALS['aksrt_wpdb'] = new FakeWpdb();
// The service reaches for the real global $wpdb, so the stand-in has to live there.
$GLOBALS['wpdb'] = $GLOBALS['aksrt_wpdb'];
/** @return list<string> */
function run(): array {
$wpdb = $GLOBALS['aksrt_wpdb'];
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$order = ['order_number' => '20261001100000000001', 'user_id' => 7, 'status' => 'paid', 'currency' => 'CNY', 'amount' => '12.50'];
\AKSRT\Payment\OrderRepository::$orders = [$order['order_number'] => $order];
// 1) create() seeds the log with the user's own submission.
$wpdb->rows = [];
$created = $service->create(7, $order['order_number'], '商品与描述不符');
if ($created instanceof \WP_Error) return ['error' => 'create failed: ' . $created->get_error_message()];
$log = $created['handling_log'];
$results = [];
$results[] = ['name' => 'create_seeds_single_user_entry', 'ok' => count($log) === 1
&& $log[0]['status'] === 'pending'
&& $log[0]['source'] === 'user'
&& $log[0]['note'] === ''
&& $log[0]['created_at'] === $created['created_at'],
'detail' => $log];
// 2) Every admin submission appends, even with an empty note; order is chronological.
$service->set_status((int) $created['id'], 'processing', '已联系仓库核实');
$service->set_status((int) $created['id'], 'completed', '已原路退款');
$service->set_status((int) $created['id'], 'rejected', '');
$log = $service->get((int) $created['id'])['handling_log'];
$results[] = ['name' => 'status_changes_append_even_without_note', 'ok' => count($log) === 4
&& $log[1] === ['status' => 'processing', 'note' => '已联系仓库核实', 'source' => 'admin', 'created_at' => $log[1]['created_at']]
&& $log[2]['status'] === 'completed' && $log[2]['note'] === '已原路退款'
&& $log[3]['status'] === 'rejected' && $log[3]['note'] === ''
&& $log[0]['created_at'] < $log[1]['created_at']
&& $log[1]['created_at'] < $log[2]['created_at']
&& $log[2]['created_at'] < $log[3]['created_at'],
'detail' => $log];
// 3) A user-side cancel is logged as a user entry.
$wpdb->rows = [];
$second = $service->create(7, $order['order_number'], '拍错了');
$service->cancel_for_user(7, $order['order_number']);
$log = $service->get((int) $second['id'])['handling_log'];
$last = end($log);
$results[] = ['name' => 'cancel_appends_user_entry', 'ok' => count($log) === 2
&& $last['status'] === 'cancelled' && $last['source'] === 'user' && $last['note'] === '',
'detail' => $log];
// 4) The log is capped, keeping the most recent entries.
$wpdb->rows = [];
$seeded = [];
for ($i = 1; $i <= 50; $i++) {
$seeded[] = ['status' => 'processing', 'note' => 'seed-' . $i, 'source' => 'admin', 'created_at' => sprintf('2026-10-01 10:%02d:00', $i)];
}
$wpdb->rows[900] = ['id' => 900, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing', 'handling_log' => wp_json_encode($seeded), 'created_at' => '2026-10-01 10:01:00'];
$service->set_status(900, 'completed', '第五十条');
$log = $service->get(900)['handling_log'];
$results[] = ['name' => 'log_is_capped_at_50', 'ok' => count($log) === 50
&& $log[0]['note'] === 'seed-2'
&& end($log)['note'] === '第五十条',
'detail' => ['count' => count($log), 'first' => $log[0]['note'], 'last' => end($log)['note']]];
// 5) Malformed rows normalize instead of fataling.
$wpdb->rows[901] = ['id' => 901, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing',
'handling_log' => wp_json_encode([
['status' => 'pending', 'note' => 'ok', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
['status' => '', 'note' => 'no status', 'source' => 'admin', 'created_at' => '2026-10-01 10:05:00'],
['note' => 'not an array value', 'source' => 'admin', 'created_at' => '2026-10-01 10:06:00'],
'garbage',
['status' => 'completed', 'source' => 'mystery', 'created_at' => '2026-10-01 10:07:00'],
])];
$wpdb->rows[902] = ['id' => 902, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending', 'handling_log' => 'not-json'];
$normalized = $service->get(901)['handling_log'];
$broken = $service->get(902)['handling_log'];
$results[] = ['name' => 'normalize_drops_malformed_entries', 'ok' => count($normalized) === 2
&& $normalized[0]['status'] === 'pending'
&& $normalized[1] === ['status' => 'completed', 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:07:00']
&& $broken === [],
'detail' => ['normalized' => $normalized, 'broken_json' => $broken]];
return $results;
}
$results = run();
$failed = array_values(array_filter($results, static fn($r): bool => empty($r['ok'])));
echo json_encode(['passed' => count($results) - count($failed), 'failed' => count($failed), 'results' => $results], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
exit($failed ? 1 : 0);
}
@@ -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'))
})
@@ -54,7 +54,14 @@ test('after-sale submissions are order-locked and admins set status directly', (
const schema = read('src/Core/DBMigratorPart2.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?gateway_request_id[\s\S]*?gateway_result[\s\S]*?completed_at/)
// 处理记录落在工单行内:每次状态提交一条「状态 + 备注 + 来源 + 时间」。
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?handling_log LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
for (const dead of ['admin_note', 'gateway_request_id', 'gateway_refund_id', 'gateway_result', 'reviewed_at', 'completed_at']) {
// 只截到本表 CREATE 语句结束,避免误判其它表里同名的 completed_at
const block = schema.slice(schema.indexOf('CREATE TABLE {$prefix}aksrt_order_aftersale'))
const column = block.slice(0, block.indexOf('ENGINE=InnoDB'))
assert.ok(!column.includes(dead), `${dead} column must be removed from aksrt_order_aftersale`)
}
assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?find_active\(\$order_number\)/)
assert.match(afterSale, /Order::STATUS_PAID/)
assert.match(afterSale, /public function set_status\(int \$id, string \$status, string \$note = ''\)/)