feat(payment): 新增「售后策略」配置与前台售后弹窗链路
- 后台「支付&付费 → 售后策略」:售后功能开关、申请时效、超时效提示(HTML)、 预置原因、申请售后说明、所需材料、申请说明占位文案、可上传图片开关; PHP schema 与 SPA schema 1:1 对齐,group 字段支持拖拽排序 - PaymentRuntime 新增售后设置解析(缺键回退 schema 默认、HTML 走 kses、 中文原因稳定 slug)与 after_sale_expired 时效判定(支付时间起算) - 售后工单支持举证图片(order_aftersale.images 列,最多 3 张, 复用评论图片上传校验)与上传路由;订单视图模型输出 after_sale 按钮状态 - 前台申请 / 超时效提示 / 售后详细三个独立弹窗(modal-mini + 蓝色炫彩头), 订单详情弹窗不再内嵌售后表单并清理相关死代码 - 新增 after-sale-policy 契约测试与运行时 harness,527 项契约测试通过
This commit is contained in:
@@ -195,8 +195,9 @@ class UserOrders {
|
||||
$can_pay = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$after_sales = $detail && class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->list_for_order($order_number) : [];
|
||||
$has_open_after_sale = (bool) array_filter($after_sales, static fn(array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending', 'processing', 'approved'], true));
|
||||
$after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null;
|
||||
$after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : [];
|
||||
$after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales);
|
||||
$updated_at = $this->resolve_updated_at($order);
|
||||
|
||||
$view = [
|
||||
@@ -224,8 +225,8 @@ class UserOrders {
|
||||
'allowed_actions' => [
|
||||
'can_pay' => $can_pay,
|
||||
'can_cancel' => $can_cancel,
|
||||
'can_after_sale' => $group === OrderCenterQuery::STATUS_PAID && !$has_open_after_sale,
|
||||
],
|
||||
'after_sale' => $after_sale_state,
|
||||
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
|
||||
'updated_at' => $updated_at,
|
||||
'status_version' => $this->resolve_status_version($order, $updated_at),
|
||||
@@ -245,6 +246,66 @@ class UserOrders {
|
||||
return $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。
|
||||
*
|
||||
* 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的
|
||||
* 全量工单(list_for_order 按 id DESC,首条即最新)。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $records
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array {
|
||||
$settings = class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings()
|
||||
: [];
|
||||
$settings = is_array($settings) ? $settings : [];
|
||||
|
||||
$open_statuses = ['pending', 'processing', 'approved'];
|
||||
$latest = null;
|
||||
$open = false;
|
||||
if ($service !== null) {
|
||||
if ($records) {
|
||||
foreach ($records as $record) {
|
||||
if ($latest === null) {
|
||||
$latest = $record;
|
||||
}
|
||||
if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) {
|
||||
$open = true;
|
||||
}
|
||||
}
|
||||
} elseif ($order_number !== '') {
|
||||
$latest = $service->latest_for_order($order_number);
|
||||
$open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 售后只对已支付订单开放;开关关闭时整体不处理售后。
|
||||
$enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']);
|
||||
$expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
&& \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at);
|
||||
$has_application = $latest !== null;
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'expired' => (bool) $expired,
|
||||
'has_application' => $has_application,
|
||||
'open' => $open,
|
||||
'can_apply' => $enabled && !$expired && !$open,
|
||||
'can_reapply' => $enabled && !$expired && !$open && $has_application,
|
||||
'latest' => $has_application ? [
|
||||
'status' => (string) ($latest['status'] ?? ''),
|
||||
'reason' => (string) ($latest['reason'] ?? ''),
|
||||
'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'] ?? ''),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function empty_view(OrderCenterQuery $query): array {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user