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:
@@ -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 = ''\)/)
|
||||
|
||||
Reference in New Issue
Block a user