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 {