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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user