refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
@@ -0,0 +1,720 @@
|
||||
<?php
|
||||
/**
|
||||
* Front-end "My Orders" view-model (all product_types).
|
||||
*
|
||||
* Shop logistics/review stay shop-only; content/VIP/recharge get type-aware cards.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class UserOrders {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated user order center payload.
|
||||
*
|
||||
* @param array{page?:int,per_page?:int,status?:string,product_type?:string} $args
|
||||
* @return array{items:array,total:int,total_pages:int,page:int,stats:array,product_types:array}
|
||||
*/
|
||||
public function get_order_center_view(int $user_id, array $args = []): array {
|
||||
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'total_pages' => 1,
|
||||
'page' => 1,
|
||||
'stats' => $this->empty_stats(),
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 20)));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
|
||||
$repo_args = [
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
];
|
||||
if ($status !== '') {
|
||||
$repo_args['status'] = $status;
|
||||
}
|
||||
if ($product_type !== '' && $product_type !== 'all') {
|
||||
$repo_args['product_type'] = $product_type;
|
||||
}
|
||||
|
||||
$repo = OrderRepository::get_instance();
|
||||
$orders = $repo->get_user_orders($user_id, $repo_args);
|
||||
$total = $repo->count_user_orders($user_id, $repo_args);
|
||||
|
||||
$items = [];
|
||||
foreach ($orders as $order) {
|
||||
$items[] = $this->decorate_order_view($order, true);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'total_pages' => max(1, (int) ceil(max(0, $total) / $per_page)),
|
||||
'page' => $page,
|
||||
'stats' => $this->get_user_order_stats($user_id),
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
}
|
||||
|
||||
public function get_order_detail_view(int $user_id, string $order_number): ?array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
|
||||
return null;
|
||||
}
|
||||
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return null;
|
||||
}
|
||||
if (class_exists(Gateway::class)) {
|
||||
$order = Gateway::get_instance()->reconcile_order($order_number, $order) ?: $order;
|
||||
}
|
||||
return $this->decorate_order_view($order, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total:int,pending:int,paid:int}
|
||||
*/
|
||||
public function get_user_order_stats(int $user_id): array {
|
||||
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
|
||||
return $this->empty_stats();
|
||||
}
|
||||
$repo = OrderRepository::get_instance();
|
||||
return [
|
||||
'total' => $repo->count_user_orders($user_id, []),
|
||||
'pending' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PENDING]),
|
||||
'paid' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PAID]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function product_type_filter_options(): array {
|
||||
$labels = \AKSRT\Core\DomainModel::order_product_type_labels();
|
||||
return array_merge(['' => __('全部类型', 'aksrt')], $labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order Normalized order row.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function decorate_order_view(array $order, bool $with_detail = false): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$status = (string) ($order['status'] ?? Order::STATUS_PENDING);
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||||
$is_points = $currency === 'points'
|
||||
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points'
|
||||
|| (!empty($meta['pay_modo']) && (string) $meta['pay_modo'] === 'points');
|
||||
|
||||
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
|
||||
$type_label = $type_labels[$product_type] ?? ($product_type !== '' ? $product_type : __('订单', 'aksrt'));
|
||||
|
||||
$title = $this->resolve_order_title($order, $meta, $product_type, $product_id, $type_label);
|
||||
$image = $this->resolve_order_image($order, $meta, $product_type, $product_id);
|
||||
$permalink = $this->resolve_order_permalink($product_type, $product_id, $meta);
|
||||
|
||||
$order_items = [];
|
||||
$has_structured_items = !empty($order_items);
|
||||
|
||||
$product = [];
|
||||
// Shop product cards were removed with the shop feature; content/VIP
|
||||
// orders fall through to the shared meta-based fields below.
|
||||
|
||||
if (!empty($product['title'])) {
|
||||
$title = (string) $product['title'];
|
||||
}
|
||||
if (!empty($product['image'])) {
|
||||
$image = (string) $product['image'];
|
||||
}
|
||||
if (!empty($product['permalink'])) {
|
||||
$permalink = (string) $product['permalink'];
|
||||
}
|
||||
|
||||
$shipping_records = [];
|
||||
$after_sale_items = [];
|
||||
$subtotal = (float) ($meta['items'][0]['line_total'] ?? $order['amount'] ?? 0);
|
||||
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
|
||||
$discount = (float) ($meta['discount'] ?? 0);
|
||||
$expires_at = (string) ($order['expires_at'] ?? '');
|
||||
$payment_value = sanitize_key((string) ($meta['payment_method']['value'] ?? $order['payment_method'] ?? 'balance'));
|
||||
|
||||
$can_pay = in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)
|
||||
&& $payment_value !== 'manual_review';
|
||||
$can_cancel = in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true);
|
||||
$can_after_sale = false;
|
||||
$can_receipt = false;
|
||||
$can_review = false;
|
||||
$can_rebuy = false;
|
||||
// Order detail modal is always available; product permalink is optional.
|
||||
$can_view = true;
|
||||
$can_view_product = $permalink !== '' && in_array($status, [
|
||||
Order::STATUS_PAID,
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
], true);
|
||||
|
||||
$view = [
|
||||
'id' => (int) ($order['id'] ?? 0),
|
||||
'order_number' => $order_number,
|
||||
'amount' => (float) ($order['amount'] ?? 0),
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $is_points
|
||||
? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'))
|
||||
: '¥',
|
||||
'is_points' => $is_points,
|
||||
'status' => $status,
|
||||
'status_label' => Order::get_valid_statuses()[$status] ?? $status,
|
||||
'product_type' => $product_type,
|
||||
'product_type_label' => $type_label,
|
||||
'product_id' => $product_id,
|
||||
'product_name' => $title,
|
||||
'product' => $product ?: [
|
||||
'title' => $title,
|
||||
'image' => $image,
|
||||
'permalink' => $permalink,
|
||||
],
|
||||
'image' => $image,
|
||||
'permalink' => $permalink,
|
||||
'created_at' => (string) ($order['created_at'] ?? ''),
|
||||
'paid_at' => (string) ($order['paid_at'] ?? ''),
|
||||
'expires_at' => $expires_at,
|
||||
'subtotal' => $subtotal,
|
||||
'shipping_fee' => $shipping_fee,
|
||||
'discount' => $discount,
|
||||
'address' => $meta['address'] ?? [],
|
||||
'payment_method' => $meta['payment_method'] ?? [
|
||||
'value' => (string) ($order['payment_method'] ?? ''),
|
||||
'label' => $this->payment_method_label((string) ($order['payment_method'] ?? '')),
|
||||
],
|
||||
'coupon' => $meta['coupon'] ?? [],
|
||||
'items' => $has_structured_items ? $order_items : ($meta['items'] ?? []),
|
||||
'shipping_method' => $meta['shipping_method'] ?? [],
|
||||
'customer_note' => $meta['customer_note'] ?? ($order['note_text'] ?? ''),
|
||||
'shipping_records' => $shipping_records,
|
||||
'pay_url' => '#',
|
||||
'actions' => [
|
||||
'can_pay' => $can_pay,
|
||||
'can_cancel' => $can_cancel,
|
||||
'can_after_sale' => $can_after_sale,
|
||||
'can_receipt' => $can_receipt,
|
||||
'can_review' => $can_review,
|
||||
'can_rebuy' => $can_rebuy,
|
||||
'can_view' => $can_view,
|
||||
'can_view_product' => $can_view_product,
|
||||
],
|
||||
'quantity' => $this->resolve_order_quantity($order_items, $meta),
|
||||
'time_left' => $this->get_order_countdown($expires_at, $status),
|
||||
'secondary_url' => $this->resolve_secondary_url($product_type),
|
||||
'secondary_label' => $this->resolve_secondary_label($product_type),
|
||||
];
|
||||
|
||||
if ($with_detail) {
|
||||
$view['after_sale_records'] = $after_sale_items;
|
||||
$view['timeline'] = $this->build_order_timeline($order, $shipping_records, $after_sale_items);
|
||||
}
|
||||
|
||||
return $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending order (any product_type).
|
||||
*
|
||||
* @return array{success:bool,message?:string,data?:array}
|
||||
*/
|
||||
public function cancel_order(int $user_id, string $order_number): array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo = OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if (!in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true)) {
|
||||
return ['success' => false, 'message' => __('当前订单不可取消。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo->update_status($order_number, Order::STATUS_CANCELLED, [
|
||||
'force' => true,
|
||||
'note' => __('用户在前台取消订单。', 'aksrt'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume payment via configured gateway (any product_type).
|
||||
*
|
||||
* @return array{success:bool,message?:string,data?:array}
|
||||
*/
|
||||
public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
// Resume via shared gateway stack (any product_type; shop removed).
|
||||
$repo = OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||
}
|
||||
|
||||
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
|
||||
if ($stored_method !== '' && $payment_method !== $stored_method) {
|
||||
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
|
||||
}
|
||||
|
||||
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
|
||||
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
||||
if (!$gateway) {
|
||||
return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')];
|
||||
}
|
||||
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
|
||||
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
||||
}
|
||||
|
||||
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
||||
$order['payment_method'] = $payment_method;
|
||||
|
||||
$payment_result = PaymentOrchestrator::pay($gateway, $order, [
|
||||
'paid_note' => __('用户在前台完成支付。', 'aksrt'),
|
||||
]);
|
||||
if (empty($payment_result['success'])) {
|
||||
return ['success' => false, 'message' => $payment_result['message']];
|
||||
}
|
||||
|
||||
if (!empty($payment_result['redirect'])) {
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'redirect' => true,
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'message' => $payment_result['message'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
'message' => $payment_result['message'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function empty_stats(): array {
|
||||
return ['total' => 0, 'pending' => 0, 'paid' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_title(array $order, array $meta, string $product_type, int $product_id, string $type_label): string {
|
||||
foreach (['product_title', 'title', 'package_title'] as $key) {
|
||||
if (!empty($meta[$key]) && is_string($meta[$key])) {
|
||||
return (string) $meta[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
$pkg = (string) ($meta['package_title'] ?? '');
|
||||
if ($pkg !== '') {
|
||||
return $pkg;
|
||||
}
|
||||
return __('VIP 会员', 'aksrt');
|
||||
}
|
||||
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return (string) ($meta['title'] ?? __('余额充值', 'aksrt'));
|
||||
}
|
||||
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
|
||||
return (string) ($meta['title'] ?? __('购买积分', 'aksrt'));
|
||||
}
|
||||
|
||||
if ($product_id > 0) {
|
||||
$post_title = get_the_title($product_id);
|
||||
if (is_string($post_title) && $post_title !== '') {
|
||||
return $post_title;
|
||||
}
|
||||
}
|
||||
|
||||
return $type_label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_image(array $order, array $meta, string $product_type, int $product_id): string {
|
||||
if (!empty($meta['thumb']) && is_string($meta['thumb'])) {
|
||||
return esc_url_raw((string) $meta['thumb']);
|
||||
}
|
||||
if ($product_id > 0 && has_post_thumbnail($product_id)) {
|
||||
$url = get_the_post_thumbnail_url($product_id, 'thumbnail');
|
||||
if (is_string($url) && $url !== '') {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_permalink(string $product_type, int $product_id, array $meta): string {
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return function_exists('aksrt_get_user_center_tab_url')
|
||||
? aksrt_get_user_center_tab_url('vip')
|
||||
: '';
|
||||
}
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
|| $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
|
||||
return function_exists('aksrt_get_user_center_tab_url')
|
||||
? aksrt_get_user_center_tab_url('credits')
|
||||
: '';
|
||||
}
|
||||
$post_id = $product_id > 0 ? $product_id : (int) ($meta['post_id'] ?? 0);
|
||||
if ($post_id > 0) {
|
||||
$link = get_permalink($post_id);
|
||||
return is_string($link) ? $link : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolve_secondary_url(string $product_type): string {
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
|
||||
}
|
||||
if (in_array($product_type, [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY,
|
||||
], true)) {
|
||||
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolve_secondary_label(string $product_type): string {
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return __('我的会员', 'aksrt');
|
||||
}
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return __('我的余额', 'aksrt');
|
||||
}
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
|
||||
return class_exists('\AKSRT\Settings\Settings')
|
||||
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
|
||||
: __('我的积分', 'aksrt');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function payment_method_label(string $method): string {
|
||||
$method = sanitize_key($method);
|
||||
$map = [
|
||||
'balance' => __('站内余额', 'aksrt'),
|
||||
'points' => __('积分支付', 'aksrt'),
|
||||
'alipay' => __('支付宝', 'aksrt'),
|
||||
'wechatpay' => __('微信支付', 'aksrt'),
|
||||
'wechat' => __('微信支付', 'aksrt'),
|
||||
'paypal' => 'PayPal',
|
||||
'stripe' => 'Stripe',
|
||||
'epay_alipay' => __('易支付·支付宝', 'aksrt'),
|
||||
'epay_wxpay' => __('易支付·微信', 'aksrt'),
|
||||
];
|
||||
return $map[$method] ?? ($method !== '' ? $method : __('—', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build chronological order progress for the detail modal.
|
||||
*
|
||||
* Driven by current status + available timestamps / related records so the
|
||||
* chain advances even when historical rows lack paid_at/delivered_at.
|
||||
*
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array> $shipping_records
|
||||
* @param array<int, array> $after_sale_records
|
||||
* @return array<int, array{time:string,label:string,key?:string}>
|
||||
*/
|
||||
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
|
||||
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
|
||||
$created_at = $this->timeline_time((string) ($order['created_at'] ?? ''));
|
||||
$paid_at = $this->timeline_time((string) ($order['paid_at'] ?? ''));
|
||||
$delivered_at = $this->timeline_time((string) ($order['delivered_at'] ?? ''));
|
||||
$completed_at = $this->timeline_time((string) ($order['completed_at'] ?? ''));
|
||||
$cancelled_at = $this->timeline_time((string) ($order['cancelled_at'] ?? ''));
|
||||
$refunded_at = $this->timeline_time((string) ($order['refunded_at'] ?? ''));
|
||||
|
||||
$shipping_created = '';
|
||||
foreach ($shipping_records as $record) {
|
||||
$t = $this->timeline_time((string) ($record['created_at'] ?? ''));
|
||||
if ($t !== '' && ($shipping_created === '' || strcmp($t, $shipping_created) < 0)) {
|
||||
$shipping_created = $t;
|
||||
}
|
||||
}
|
||||
if ($delivered_at === '' && $shipping_created !== '') {
|
||||
$delivered_at = $shipping_created;
|
||||
}
|
||||
|
||||
$paid_like = [
|
||||
Order::STATUS_PAID,
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
Order::STATUS_REFUNDING,
|
||||
Order::STATUS_REFUNDED,
|
||||
];
|
||||
$ship_like = [
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
];
|
||||
$done_like = [
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
];
|
||||
|
||||
$timeline = [];
|
||||
$push = static function (array &$out, string $key, string $label, string $time): void {
|
||||
if ($label === '') {
|
||||
return;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'time' => $time,
|
||||
];
|
||||
};
|
||||
|
||||
// 1) Always: order created.
|
||||
$push(
|
||||
$timeline,
|
||||
'created',
|
||||
__('订单创建', 'aksrt'),
|
||||
$created_at !== '' ? $created_at : ''
|
||||
);
|
||||
|
||||
// 2) Paid (status-driven; fall back to created_at when paid_at missing).
|
||||
if ($paid_at !== '' || in_array($status, $paid_like, true)) {
|
||||
$push(
|
||||
$timeline,
|
||||
'paid',
|
||||
__('完成支付', 'aksrt'),
|
||||
$paid_at !== '' ? $paid_at : $created_at
|
||||
);
|
||||
}
|
||||
|
||||
// 3) Shipped / delivered — only when logistics exists or status reached it.
|
||||
$has_shipping = $shipping_records !== [];
|
||||
if (
|
||||
$delivered_at !== ''
|
||||
|| $has_shipping
|
||||
|| in_array($status, $ship_like, true)
|
||||
) {
|
||||
// Virtual / content flows often jump paid → completed without logistics;
|
||||
// only show the ship step when we have evidence of shipping.
|
||||
if ($delivered_at !== '' || $has_shipping || $status === Order::STATUS_DELIVERED) {
|
||||
$push(
|
||||
$timeline,
|
||||
'delivered',
|
||||
__('订单发货', 'aksrt'),
|
||||
$delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Completed / pending review.
|
||||
if ($completed_at !== '' || in_array($status, $done_like, true)) {
|
||||
$done_label = $status === Order::STATUS_PENDING_REVIEW
|
||||
? __('待评价', 'aksrt')
|
||||
: __('交易完成', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'completed',
|
||||
$done_label,
|
||||
$completed_at !== ''
|
||||
? $completed_at
|
||||
: ($delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at))
|
||||
);
|
||||
}
|
||||
|
||||
// 5) Terminal cancel / expire / refund.
|
||||
if ($status === Order::STATUS_CANCELLED || $cancelled_at !== '') {
|
||||
$push(
|
||||
$timeline,
|
||||
'cancelled',
|
||||
__('订单取消', 'aksrt'),
|
||||
$cancelled_at !== '' ? $cancelled_at : $created_at
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_EXPIRED) {
|
||||
$push(
|
||||
$timeline,
|
||||
'expired',
|
||||
__('订单过期', 'aksrt'),
|
||||
$cancelled_at !== '' ? $cancelled_at : $created_at
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_REFUNDING) {
|
||||
$push(
|
||||
$timeline,
|
||||
'refunding',
|
||||
__('退款处理中', 'aksrt'),
|
||||
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_REFUNDED || $refunded_at !== '') {
|
||||
$push(
|
||||
$timeline,
|
||||
'refunded',
|
||||
__('完成退款', 'aksrt'),
|
||||
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
|
||||
// Extra logistics / after-sale events (detail lines).
|
||||
foreach ($shipping_records as $record) {
|
||||
$tracking = trim((string) ($record['tracking_no'] ?? ''));
|
||||
$carrier = trim((string) ($record['carrier_label'] ?? $record['carrier'] ?? ''));
|
||||
$status_l = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
|
||||
$parts = array_filter([$carrier, $tracking, $status_l], static fn($v) => $v !== '');
|
||||
$label = $parts !== []
|
||||
? sprintf(__('物流更新:%s', 'aksrt'), implode(' / ', $parts))
|
||||
: __('物流更新', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'shipping',
|
||||
$label,
|
||||
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
foreach ($after_sale_records as $record) {
|
||||
$reason = trim((string) ($record['reason'] ?? ''));
|
||||
$type_l = trim((string) ($record['type_label'] ?? $record['type'] ?? ''));
|
||||
$as_status = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
|
||||
$parts = array_filter([$type_l, $as_status, $reason], static fn($v) => $v !== '');
|
||||
$label = $parts !== []
|
||||
? sprintf(__('售后:%s', 'aksrt'), implode(' — ', $parts))
|
||||
: __('售后申请', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'after_sale',
|
||||
$label,
|
||||
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
usort($timeline, static function ($a, $b) {
|
||||
$ta = (string) ($a['time'] ?? '');
|
||||
$tb = (string) ($b['time'] ?? '');
|
||||
if ($ta === $tb) {
|
||||
// Keep canonical pipeline order when times collide / missing.
|
||||
$rank = [
|
||||
'created' => 10,
|
||||
'paid' => 20,
|
||||
'delivered' => 30,
|
||||
'shipping' => 35,
|
||||
'completed' => 40,
|
||||
'refunding' => 50,
|
||||
'refunded' => 60,
|
||||
'cancelled' => 70,
|
||||
'expired' => 80,
|
||||
'after_sale'=> 90,
|
||||
];
|
||||
$ra = $rank[(string) ($a['key'] ?? '')] ?? 99;
|
||||
$rb = $rank[(string) ($b['key'] ?? '')] ?? 99;
|
||||
return $ra <=> $rb;
|
||||
}
|
||||
if ($ta === '') {
|
||||
return 1;
|
||||
}
|
||||
if ($tb === '') {
|
||||
return -1;
|
||||
}
|
||||
return strcmp($ta, $tb);
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mysql datetime for timeline display (empty when invalid/zero).
|
||||
*/
|
||||
private function timeline_time(string $value): string {
|
||||
$value = trim($value);
|
||||
if ($value === '' || str_starts_with($value, '0000-00-00')) {
|
||||
return '';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function get_order_countdown(string $expires_at, string $status): int {
|
||||
if ($expires_at === '' || !in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)) {
|
||||
return 0;
|
||||
}
|
||||
$left = strtotime($expires_at) - time();
|
||||
return max(0, (int) $left);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum line quantities for display (shop multi-item or single meta item).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $order_items
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_quantity(array $order_items, array $meta): int {
|
||||
$qty = 0;
|
||||
$source = !empty($order_items) ? $order_items : (is_array($meta['items'] ?? null) ? $meta['items'] : []);
|
||||
foreach ($source as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$qty += max(1, (int) ($row['quantity'] ?? $row['qty'] ?? 1));
|
||||
}
|
||||
if ($qty <= 0) {
|
||||
$qty = max(1, (int) ($meta['quantity'] ?? $meta['qty'] ?? 1));
|
||||
}
|
||||
return $qty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user