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
+76 -10
View File
@@ -351,6 +351,13 @@ class PayOrders {
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">&larr; ' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
// 售后记录区块:工单卡片 + 处理记录列表(与售后面板共用 .aksrt-as-log 样式)。
echo '<style>'
. '.aksrt-as-ticket{padding:10px 0;border-bottom:1px solid #f0f0f1;}'
. '.aksrt-as-ticket:last-child{padding-bottom:0;border-bottom:0;}'
. '.aksrt-as-ticket__head{margin:0 0 4px;}'
. $this->after_sale_log_css()
. '</style>';
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
$rows = [
__('订单号', 'aksrt') => $order_number,
@@ -371,12 +378,17 @@ class PayOrders {
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
if ($after_sales) {
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
$status_labels = ['pending' => '待审核', 'processing' => '处理中', 'completed' => '已完成', 'rejected' => '已拒绝', 'cancelled' => '已取消'];
$status_labels = $this->after_sale_status_labels();
foreach ($after_sales as $application) {
echo '<p><strong>' . esc_html($status_labels[(string) ($application['status'] ?? '')] ?? (string) ($application['status'] ?? '')) . '</strong> · ' . esc_html((string) ($application['refund_amount'] ?? '')) . '<br>' . esc_html((string) ($application['reason'] ?? ''));
if (!empty($application['admin_note'])) echo '<br>' . esc_html__('处理备注:', 'aksrt') . esc_html((string) $application['admin_note']);
if (!empty($application['gateway_result'])) echo '<br><code>' . esc_html((string) $application['gateway_result']) . '</code>';
echo '</p>';
$as_status = (string) ($application['status'] ?? '');
echo '<div class="aksrt-as-ticket is-' . esc_attr($as_status) . '">';
echo '<p class="aksrt-as-ticket__head"><strong>' . esc_html($status_labels[$as_status] ?? $as_status) . '</strong>'
. ' <span class="description">' . esc_html((string) ($application['refund_amount'] ?? '')) . '</span>'
. ' <span class="description">' . esc_html((string) ($application['created_at'] ?? '')) . '</span></p>';
echo '<p>' . nl2br(esc_html((string) ($application['reason'] ?? ''))) . '</p>';
// 处理记录:后台每一次提交都留一条(状态 + 备注 + 时间),与售后面板同款标记。
echo $this->render_after_sale_log((array) ($application['handling_log'] ?? []), $status_labels); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</div>';
}
echo '</td></tr>';
}
@@ -429,7 +441,7 @@ class PayOrders {
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_AFTER_SALES_SLUG) . '" />';
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
foreach ([\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消'] as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
foreach ($this->after_sale_status_labels() as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
@@ -452,12 +464,13 @@ class PayOrders {
. '.aksrt-as-review textarea{flex:1 1 240px;min-width:0;box-sizing:border-box;margin:0;}'
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}'
. '.aksrt-as-field--wide{grid-column:1/-1;}'
. $this->after_sale_log_css()
. '@media (min-width:1500px){.aksrt-as-detail-grid{grid-template-columns:repeat(5,minmax(0,1fr));}}'
. '@media (max-width:600px){.aksrt-as-detail-grid{grid-template-columns:minmax(0,1fr);}}'
. '</style>';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>申请资料</th><th>申请说明</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="9">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
$status_labels = [\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消'];
$status_labels = $this->after_sale_status_labels();
foreach ($items as $item) {
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
$user = $uid > 0 ? get_userdata($uid) : null;
@@ -496,7 +509,6 @@ class PayOrders {
$desc_cell = ($desc_text !== '' ? nl2br(esc_html($desc_text)) : '') . ($thumbs ? '<div class="aksrt-as-desc-thumbs" style="margin-top:6px;">' . implode('', $thumbs) . '</div>' : '');
if ($desc_cell === '') $desc_cell = '—';
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
$admin_note = (string) ($item['admin_note'] ?? '');
$order_refunded = is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与状态面板
echo '<tr class="aksrt-as-row">';
@@ -520,13 +532,15 @@ class PayOrders {
__('当前状态', 'aksrt') => esc_html($status_label),
__('售后原因', 'aksrt') => $reason_cell,
__('申请资料', 'aksrt') => $materials_cell,
__('处理备注', 'aksrt') => $admin_note !== '' ? esc_html($admin_note) : '—',
__('审核时间', 'aksrt') => !empty($item['reviewed_at']) ? esc_html((string) $item['reviewed_at']) : '—',
];
echo '<div class="aksrt-as-detail"><div class="aksrt-as-detail-grid">';
foreach ($detail_fields as $field_label => $field_value) {
echo '<span class="aksrt-as-field"><span class="aksrt-as-label">' . esc_html($field_label) . '</span><span class="aksrt-as-value">' . $field_value . '</span></span>';
}
// 处理备注:单独占整行,放在申请说明上方;每次提交一条,含状态与时间。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('处理备注', 'aksrt') . '</span><span class="aksrt-as-value">'
. $this->render_after_sale_log((array) ($item['handling_log'] ?? []), $status_labels) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. '</span></span>';
// 举证图片并入申请说明,单独占整行展示。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('申请说明', 'aksrt') . '</span><span class="aksrt-as-value">' . $desc_cell . '</span></span>';
echo '</div>';
@@ -619,6 +633,58 @@ class PayOrders {
];
}
/** 售后状态中文标签:订单明细页与售后面板(含筛选下拉)共用,取代两处内联数组。 */
/** 处理记录列表样式:两个后台页面各自输出同一套规则,不依赖对方加载。 */
private function after_sale_log_css(): string {
return '.aksrt-as-log{display:grid;gap:5px;margin:6px 0 0;padding:0;list-style:none;}'
. '.aksrt-as-log__item{display:flex;flex-wrap:wrap;gap:3px 10px;align-items:baseline;font-size:12px;line-height:1.7;}'
. '.aksrt-as-log__time{color:#646970;font-variant-numeric:tabular-nums;}'
. '.aksrt-as-log__status{font-weight:600;}'
. '.aksrt-as-log__item.is-pending .aksrt-as-log__status{color:#b26200;}'
. '.aksrt-as-log__item.is-processing .aksrt-as-log__status{color:#2271b1;}'
. '.aksrt-as-log__item.is-completed .aksrt-as-log__status{color:#007017;}'
. '.aksrt-as-log__item.is-rejected .aksrt-as-log__status,.aksrt-as-log__item.is-cancelled .aksrt-as-log__status{color:#b32d2e;}'
. '.aksrt-as-log__note{flex:1 1 100%;color:#3c434a;}';
}
private function after_sale_status_labels(): array {
return [
\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核',
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中',
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成',
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝',
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消',
];
}
/**
* 处理记录列表:每一次状态提交一条「时间 + 状态 + 备注」,备注为空也留痕。
* 后台订单明细页与售后面板共用同一套标记与样式。
*
* @param list<array<string,mixed>> $log
* @param array<string,string> $status_labels
*/
private function render_after_sale_log(array $log, array $status_labels): string {
if (!$log) {
return '<p class="description">' . esc_html__('暂无处理记录', 'aksrt') . '</p>';
}
$service = \AKSRT\Payment\AfterSaleService::class;
$html = '<ul class="aksrt-as-log">';
foreach ($log as $entry) {
$status = (string) ($entry['status'] ?? '');
$label = (string) ($entry['source'] ?? '') === $service::SOURCE_USER
? ($status === $service::STATUS_CANCELLED ? esc_html__('用户撤销售后', 'aksrt') : esc_html__('用户提交申请', 'aksrt'))
: ($status_labels[$status] ?? $status);
$note = (string) ($entry['note'] ?? '');
$html .= '<li class="aksrt-as-log__item is-' . esc_attr($status) . '">'
. '<span class="aksrt-as-log__time">' . esc_html((string) ($entry['created_at'] ?? '')) . '</span>'
. '<span class="aksrt-as-log__status">' . esc_html($label) . '</span>'
. '<span class="aksrt-as-log__note">' . ($note !== '' ? nl2br(esc_html($note)) : '—') . '</span>'
. '</li>';
}
return $html . '</ul>';
}
public function handle_after_sale_status(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
+4 -1
View File
@@ -17,7 +17,10 @@ class DBMigrator {
// 2.5.1: order_aftersale 新增 images 列(售后举证图片)。dbDelta 仅在版本变化时
// 重跑,新增/调整列必须同步升版本,否则老安装不会执行 ALTER TABLE。
// 2.5.2: order_aftersale 新增 apply_form 列(结构化申请表单:预置原因 / 申请资料 / 申请说明)。
const SCHEMA_VERSION = '2.5.2';
// 2.6.0: order_aftersale 新增 handling_log 列(处理记录:每次状态提交一条
// 「状态 + 备注 + 来源 + 时间」),取代单一的 admin_note / reviewed_at / completed_at,
// 并移除从未被写入的 gateway_request_id / gateway_refund_id / gateway_result。
const SCHEMA_VERSION = '2.6.0';
const OPT_KEY = 'aksrt_db_schema_version';
+1 -6
View File
@@ -213,15 +213,10 @@ trait DBMigratorPart2 {
reason TEXT NOT NULL,
apply_form LONGTEXT DEFAULT NULL,
images LONGTEXT DEFAULT NULL,
handling_log LONGTEXT DEFAULT NULL,
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_note TEXT DEFAULT NULL,
gateway_request_id VARCHAR(128) DEFAULT NULL,
gateway_refund_id VARCHAR(128) DEFAULT NULL,
gateway_result LONGTEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
reviewed_at DATETIME DEFAULT NULL,
completed_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_order_status (order_number, status),
KEY idx_user_created (user_id, created_at),
+89 -8
View File
@@ -11,6 +11,11 @@ final class AfterSaleService {
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';
/** 处理记录上限:与 MetaAssetService::MAX_RECORDS 同口径,超出后只保留最近的一段。 */
private const MAX_LOG_ENTRIES = 50;
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
@@ -153,13 +158,15 @@ final class AfterSaleService {
}
$amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
$now = current_time('mysql');
$inserted = $wpdb->insert($this->table(), [
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
'reason' => $reason,
'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'images' => wp_json_encode($images), 'refund_amount' => $amount,
'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
'status' => self::STATUS_PENDING, 'created_at' => $now,
'handling_log' => $this->encode_log([$this->log_entry(self::STATUS_PENDING, '', self::SOURCE_USER, $now)]),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
@@ -212,6 +219,15 @@ final class AfterSaleService {
return $clean;
}
/**
* 退款金额:积分单取整,现金单经 AssetAmount 归一为两位小数;
* 金额无法解析时返回 null,由 create() 拒掉这次申请。
*/
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
public function latest_for_order(string $order_number): ?array {
global $wpdb;
@@ -229,6 +245,7 @@ final class AfterSaleService {
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
$this->append_log((int) $row['id'], $row, self::STATUS_CANCELLED, '', self::SOURCE_USER);
return $this->get((int) $row['id']);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
@@ -298,6 +315,10 @@ final class AfterSaleService {
/**
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
*
* 每次提交都往 handling_log 落一条「状态 + 备注 + 来源 + 时间」,备注为空也留痕,
* 后台与前台才能回放整条处理链路。读取-追加-写回需要与 create()/cancel_for_user()
* 同一把订单锁,否则两条并发提交会互相覆盖日志。
*/
public function set_status(int $id, string $status, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
@@ -305,12 +326,48 @@ final class AfterSaleService {
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
$data = ['status' => $status];
if ($note !== '') $data['admin_note'] = $note;
if ($status !== self::STATUS_PENDING) $data['reviewed_at'] = current_time('mysql');
if (!$this->update($id, $data)) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
return $this->get($id);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5((string) ($row['order_number'] ?? ''));
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
// 锁内重读:追加必须基于最新快照,否则两条并发提交仍会互相覆盖日志。
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if (!$this->update($id, ['status' => $status])) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
$this->append_log($id, $row, $status, $note, self::SOURCE_ADMIN);
return $this->get($id);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/**
* 追加一条处理记录。日志按时间正序存放,超出 MAX_LOG_ENTRIES 条时只保留最近的一段。
*
* @param array<string,mixed> $current 追加前的工单行(含已规范化的 handling_log)。
*/
private function append_log(int $id, array $current, string $status, string $note, string $source): void {
$log = is_array($current['handling_log'] ?? null) ? $current['handling_log'] : [];
$log[] = $this->log_entry($status, $note, $source, current_time('mysql'));
if (count($log) > self::MAX_LOG_ENTRIES) {
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
}
$this->update($id, ['handling_log' => $this->encode_log($log)]);
}
/** @return array<string,string> */
private function log_entry(string $status, string $note, string $source, string $created_at): array {
return [
'status' => $status,
'note' => $note,
'source' => $source,
'created_at' => $created_at,
];
}
private function encode_log(array $log): string {
return wp_json_encode($log, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
@@ -345,6 +402,30 @@ final class AfterSaleService {
: [];
$apply_form = json_decode((string) ($row['apply_form'] ?? ''), true);
$row['apply_form'] = is_array($apply_form) ? $apply_form : null;
$row['handling_log'] = $this->normalize_log(json_decode((string) ($row['handling_log'] ?? ''), true));
return $row;
}
/**
* 处理记录规范化:只保留 status 非空的条目,source 只认 user,其余归 admin;
* 非数组(含损坏 JSON)整体视为空,界面对老工单按「暂无处理记录」处理。
*
* @return list<array<string,string>>
*/
private function normalize_log($log): array {
if (!is_array($log)) return [];
$clean = [];
foreach ($log as $entry) {
if (!is_array($entry)) continue;
$status = sanitize_key((string) ($entry['status'] ?? ''));
if ($status === '') continue;
$clean[] = [
'status' => $status,
'note' => (string) ($entry['note'] ?? ''),
'source' => (string) ($entry['source'] ?? '') === self::SOURCE_USER ? self::SOURCE_USER : self::SOURCE_ADMIN,
'created_at' => (string) ($entry['created_at'] ?? ''),
];
}
return $clean;
}
}
+2 -3
View File
@@ -308,9 +308,8 @@ class UserOrders {
'images' => array_values((array) ($latest['images'] ?? [])),
'refund_amount' => (string) ($latest['refund_amount'] ?? '0'),
'created_at' => (string) ($latest['created_at'] ?? ''),
'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''),
'completed_at' => (string) ($latest['completed_at'] ?? ''),
'admin_note' => (string) ($latest['admin_note'] ?? ''),
// 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。
'handling_log' => array_values((array) ($latest['handling_log'] ?? [])),
] : null,
];
}