From 4c5e4ab22243f951e3b616f3a3344c1f778754be Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 15 Jul 2026 03:05:01 +0800 Subject: [PATCH] feat(admin): pending shipment board with immediate ship actions Add paid-but-unshipped order queue on the shipping page (zibll-style), repository query via NOT EXISTS on order_shipping, overview pending card, and create-shipping redirect back to the pending tab. --- docs/zibll-gap-matrix.md | 4 +- .../native/class-aksrt-admin-pay-menus.php | 125 +++++++++++++++++- inc/classes/class-aksrt-order-repository.php | 57 ++++++++ 3 files changed, 180 insertions(+), 6 deletions(-) diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index ff74df8d..ae2394c3 100644 --- a/docs/zibll-gap-matrix.md +++ b/docs/zibll-gap-matrix.md @@ -99,8 +99,8 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no | 2b Shipping/after-sale/coupons/income/rebate | done | | 3 users.php submenus | done | | 4 SPA strip | done (ops routes redirect to native) | -| 5+ behavior parity | in progress — shipping list create + order links; after-sale filters/actions; order note/quick actions; coupons field parity + batch/enable; charge-cards expires + code dump + search/labels | -| 5 remaining | product/forum runtime field parity; pending-to-ship board; express timeline; coupon product_scope UI | +| 5+ behavior parity | in progress — shipping list create + pending-to-ship board; after-sale filters/actions; order note/quick actions; coupons field parity + batch/enable; charge-cards expires + code dump + search/labels | +| 5 remaining | product/forum runtime field parity; express timeline; coupon product_scope UI | ## Dual-UI rule diff --git a/inc/classes/admin/native/class-aksrt-admin-pay-menus.php b/inc/classes/admin/native/class-aksrt-admin-pay-menus.php index 4d5b1e63..88068105 100644 --- a/inc/classes/admin/native/class-aksrt-admin-pay-menus.php +++ b/inc/classes/admin/native/class-aksrt-admin-pay-menus.php @@ -259,9 +259,14 @@ class AKSRT_Admin_Pay_Menus { echo '

' . esc_html__('运营数据入口(对齐 zibll 商城中心)。配置项仍在 AKSRT 主题设置 SPA。', 'aksrt') . '

'; echo '
'; + $pending_ship = 0; + if (class_exists('AKSRT_Order_Repository')) { + $pending_ship = (int) (AKSRT_Order_Repository::get_instance()->get_pending_shipment_orders(['limit' => 1])['total'] ?? 0); + } $this->stat_card(__('订单总数', 'aksrt'), (string) (int) ($order_stats['total'] ?? 0), $this->page_url('aksrt_orders')); $this->stat_card(__('待支付', 'aksrt'), (string) (int) ($order_stats['pending'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'pending'])); $this->stat_card(__('已支付', 'aksrt'), (string) (int) ($order_stats['paid'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'paid'])); + $this->stat_card(__('待发货', 'aksrt'), (string) $pending_ship, $this->page_url('aksrt_shipping', ['view' => 'pending'])); $this->stat_card(__('营收', 'aksrt'), number_format((float) ($order_stats['revenue'] ?? 0), 2), $this->page_url('aksrt_orders')); $this->stat_card(__('待审提现', 'aksrt'), (string) (int) ($withdraw_stats['pending'] ?? 0), $this->page_url('aksrt_withdraw', ['status' => 'pending'])); $this->stat_card(__('可用卡密', 'aksrt'), (string) (int) ($card_stats['active'] ?? 0), $this->page_url('aksrt_charge_cards', ['status' => 'active'])); @@ -1055,6 +1060,10 @@ class AKSRT_Admin_Pay_Menus { return; } + $view = isset($_GET['view']) ? sanitize_key((string) wp_unslash($_GET['view'])) : 'records'; + if (!in_array($view, ['records', 'pending'], true)) { + $view = 'records'; + } $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; $search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; $page_num = max(1, (int) ($_GET['paged'] ?? 1)); @@ -1063,12 +1072,39 @@ class AKSRT_Admin_Pay_Menus { $stats = $svc->get_stats(); $status_options = $this->shipping_status_options(true); + $pending_total = 0; + if (class_exists('AKSRT_Order_Repository')) { + $pending_total = (int) (AKSRT_Order_Repository::get_instance()->get_pending_shipment_orders(['limit' => 1, 'search' => ''])['total'] ?? 0); + } + echo '

' . esc_html(sprintf( - /* translators: 1: total */ - __('物流记录共 %d 条', 'aksrt'), - (int) ($stats['total'] ?? 0) + /* translators: 1: shipping records 2: pending shipment orders */ + __('物流记录 %1$d 条 · 待发货订单 %2$d', 'aksrt'), + (int) ($stats['total'] ?? 0), + $pending_total )) . '

'; + echo ''; + + if ($view === 'pending') { + $this->render_pending_shipment_board($search, $page_num, $per_page); + $this->render_footer(); + return; + } + echo '

' . esc_html__('新建发货', 'aksrt') . '

'; echo '
'; echo ''; @@ -1085,6 +1121,7 @@ class AKSRT_Admin_Pay_Menus { echo ''; echo ''; + echo ''; echo ''; + echo ''; + echo ' '; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + + $result = AKSRT_Order_Repository::get_instance()->get_pending_shipment_orders([ + 'search' => $search, + 'limit' => $per_page, + 'offset' => ($page_num - 1) * $per_page, + ]); + $items = $result['items'] ?? []; + $total = (int) ($result['total'] ?? 0); + $pages = max(1, (int) ($result['max_pages'] ?? 1)); + $labels = class_exists('AKSRT_Domain_Model') + ? AKSRT_Domain_Model::order_status_labels() + : []; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + if (!$items) { + echo ''; + } + + foreach ($items as $row) { + $order_number = (string) ($row['order_number'] ?? ''); + $user_id = (int) ($row['user_id'] ?? 0); + $user = $user_id > 0 ? get_userdata($user_id) : null; + $status_key = (string) ($row['status'] ?? ''); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
' . esc_html__('订单号', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('金额', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('支付时间', 'aksrt') . '' . esc_html__('立即发货', 'aksrt') . '
' . esc_html__('暂无待发货订单', 'aksrt') . '
' . esc_html($order_number) . '' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '' . esc_html((string) ($row['product_type'] ?? '')) . '' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '' . esc_html($labels[$status_key] ?? $status_key) . '' . esc_html((string) ($row['paid_at'] ?? $row['created_at'] ?? '')) . ''; + echo '
'; + echo ''; + echo ''; + echo ''; + wp_nonce_field('aksrt_pay_create_shipping_' . $order_number); + $this->render_carrier_field(''); + echo ''; + echo ''; + submit_button(__('发货', 'aksrt'), 'primary small', '', false); + echo '
'; + echo '
'; + $this->render_pagination($pages, $page_num); + } + public function handle_update_shipping(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); @@ -1207,7 +1313,18 @@ class AKSRT_Admin_Pay_Menus { private function redirect_shipping_create_result(bool $ok, string $order_number, string $redirect_page): void { $msg = $ok ? 'shipping_created' : 'shipping_create_failed'; if ($redirect_page === 'shipping') { - wp_safe_redirect($this->page_url('aksrt_shipping', ['aksrt_pay_msg' => $msg, 's' => $order_number])); + wp_safe_redirect($this->page_url('aksrt_shipping', [ + 'view' => 'records', + 'aksrt_pay_msg' => $msg, + 's' => $order_number, + ])); + exit; + } + if ($redirect_page === 'pending') { + wp_safe_redirect($this->page_url('aksrt_shipping', [ + 'view' => 'pending', + 'aksrt_pay_msg' => $msg, + ])); exit; } wp_safe_redirect($this->page_url('aksrt_orders', [ diff --git a/inc/classes/class-aksrt-order-repository.php b/inc/classes/class-aksrt-order-repository.php index 4828070f..f6ee9f99 100644 --- a/inc/classes/class-aksrt-order-repository.php +++ b/inc/classes/class-aksrt-order-repository.php @@ -348,6 +348,63 @@ class AKSRT_Order_Repository { /** * 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配) */ + /** + * Paid orders that still need shipping (no shipping row yet). + * + * @return array{items: array>, total: int, page: int, max_pages: int} + */ + public function get_pending_shipment_orders(array $args = []): array { + global $wpdb; + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + $search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? '')); + + $order_table = $this->table(); + // Must match AKSRT_Shipping::table() key. + $shipping_table = class_exists('AKSRT_DB_Migrator') + ? AKSRT_DB_Migrator::table('order_shipping') + : $wpdb->prefix . 'aksrt_order_shipping'; + + $where = ["o.status = %s"]; + $params = ['paid']; + + if ($search !== '') { + $where[] = '(o.order_number LIKE %s OR o.product_type LIKE %s)'; + $like = '%' . $wpdb->esc_like($search) . '%'; + $params[] = $like; + $params[] = $like; + } + + // NOT EXISTS avoids ONLY_FULL_GROUP_BY issues from LEFT JOIN + GROUP BY. + $where[] = "NOT EXISTS ( + SELECT 1 FROM {$shipping_table} s + WHERE s.order_number = o.order_number + )"; + $where_sql = 'WHERE ' . implode(' AND ', $where); + + $count_sql = "SELECT COUNT(*) FROM {$order_table} o {$where_sql}"; + $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)); + + $sql = "SELECT o.* + FROM {$order_table} o + {$where_sql} + ORDER BY COALESCE(o.paid_at, o.created_at) DESC, o.id DESC + LIMIT %d OFFSET %d"; + $query_params = $params; + $query_params[] = $limit; + $query_params[] = $offset; + $rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A); + $items = array_map([$this, 'normalize_row'], (array) $rows); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => (int) floor($offset / $limit) + 1, + 'max_pages' => (int) ceil(max(1, $total) / $limit), + ]; + } + public function get_orders_admin(array $args = [], string $search = ''): array { global $wpdb;