insert($this->table(), [ 'order_number' => $order_number, 'user_id' => $user_id, 'type' => $type, 'reason' => $reason, 'status' => self::STATUS_PENDING, 'amount' => $amount, 'note' => $note, 'created_at' => current_time('mysql'), ]); return $result ? (int) $wpdb->insert_id : 0; } /** * 获取售后工单列表 */ public function get_list(array $args = []): array { global $wpdb; $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); $include_order_context = !empty($args['include_order_context']); $status = sanitize_key((string) ($args['status'] ?? '')); $stage = sanitize_key((string) ($args['stage'] ?? '')); $order_number = sanitize_text_field((string) ($args['order_number'] ?? '')); $type = sanitize_key((string) ($args['type'] ?? '')); $date_from = sanitize_text_field((string) ($args['date_from'] ?? '')); $date_to = sanitize_text_field((string) ($args['date_to'] ?? '')); $where = []; $params = []; if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; } if ($stage !== '' && $status === '') { $stage_statuses = $this->map_stage_to_statuses($stage); if (!empty($stage_statuses)) { $where[] = 'status IN (' . implode(', ', array_fill(0, count($stage_statuses), '%s')) . ')'; $params = array_merge($params, $stage_statuses); } } if ($order_number !== '') { $where[] = 'order_number = %s'; $params[] = $order_number; } if ($type !== '') { $where[] = 'type = %s'; $params[] = $type; } if ($date_from !== '') { $where[] = 'created_at >= %s'; $params[] = $date_from . ' 00:00:00'; } if ($date_to !== '') { $where[] = 'created_at <= %s'; $params[] = $date_to . ' 23:59:59'; } $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; $total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql); $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; $params[] = $limit; $params[] = $offset; $items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); $items = array_map([$this, 'decorate_record'], (array) $items); if ($include_order_context && !empty($items) && class_exists('AKSRT_Order_Repository')) { $contexts = AKSRT_Order_Repository::get_instance()->get_admin_order_context_map(array_column($items, 'order_number')); foreach ($items as &$item) { $item['order_context'] = $contexts[(string) ($item['order_number'] ?? '')] ?? []; } unset($item); } return [ 'items' => $items ?: [], 'total' => $total, 'page' => floor($offset / $limit) + 1, 'max_pages' => ceil($total / $limit), ]; } /** * 获取单个售后工单 */ public function get(int $id): ?array { global $wpdb; $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->table()} WHERE id = %d", $id ), ARRAY_A); return $row ? $this->decorate_record($row) : null; } /** * 获取单个售后单并附带订单上下文。 */ public function get_with_order_context(int $id): ?array { $record = $this->get($id); if (!$record) { return null; } $record['order_context'] = class_exists('AKSRT_Order_Repository') ? AKSRT_Order_Repository::get_instance()->get_admin_order_context((string) ($record['order_number'] ?? '')) : []; return $record; } public static function get_type_options(): array { return [ ['value' => self::TYPE_REFUND, 'label' => __('退款', 'aksrt')], ['value' => self::TYPE_EXCHANGE, 'label' => __('换货', 'aksrt')], ['value' => self::TYPE_REPAIR, 'label' => __('维修', 'aksrt')], ]; } public static function get_status_options(): array { return [ ['value' => self::STATUS_PENDING, 'label' => __('待处理', 'aksrt')], ['value' => self::STATUS_PROCESSING, 'label' => __('处理中', 'aksrt')], ['value' => self::STATUS_APPROVED, 'label' => __('已通过', 'aksrt')], ['value' => self::STATUS_REJECTED, 'label' => __('已拒绝', 'aksrt')], ['value' => self::STATUS_COMPLETED, 'label' => __('已完成', 'aksrt')], ['value' => self::STATUS_CANCELLED, 'label' => __('已取消', 'aksrt')], ]; } public static function get_stage_options(): array { return [ ['value' => 'review', 'label' => __('审核处理中', 'aksrt')], ['value' => 'approved', 'label' => __('已同意,待完成', 'aksrt')], ['value' => 'completed', 'label' => __('售后完成', 'aksrt')], ['value' => 'rejected', 'label' => __('售后拒绝', 'aksrt')], ['value' => 'cancelled', 'label' => __('已取消', 'aksrt')], ]; } /** * R68: Get after-sale reason options from shop settings (§6.8). * * @return array{refund: array, return: array} */ public static function get_reason_options(): array { if (!class_exists('AKSRT_Settings')) { return ['refund' => [], 'return' => []]; } $shop = AKSRT_Settings::get_shop_settings(); $refund_text = (string) ($shop['timing']['after_sale_reason_refund'] ?? ''); $return_text = (string) ($shop['timing']['after_sale_reason_return'] ?? ''); return [ 'refund' => self::parse_reason_lines($refund_text), 'return' => self::parse_reason_lines($return_text), ]; } private static function parse_reason_lines(string $text): array { if ($text === '') { return []; } $lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $text))); $result = []; foreach (array_values($lines) as $i => $line) { $result[] = [ 'id' => $i + 1, 'label' => $line, ]; } return $result; } /** * @param array $order_numbers * @return array> */ public function get_summary_map(array $order_numbers): array { global $wpdb; $order_numbers = array_values(array_unique(array_filter(array_map( static function ($order_number): string { return sanitize_text_field((string) $order_number); }, $order_numbers )))); if (empty($order_numbers)) { return []; } $map = []; foreach ($order_numbers as $order_number) { $map[$order_number] = [ 'count' => 0, 'latest' => [], ]; } $placeholders = implode(', ', array_fill(0, count($order_numbers), '%s')); $sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders}) ORDER BY created_at DESC, id DESC"; $rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A); foreach ((array) $rows as $row) { $record = $this->decorate_record($row); $order_number = (string) ($record['order_number'] ?? ''); if (!isset($map[$order_number])) { continue; } $map[$order_number]['count']++; if (empty($map[$order_number]['latest'])) { $map[$order_number]['latest'] = $this->build_summary_item($record); } } return $map; } /** * 更新售后工单状态 */ public function update_status(int $id, string $new_status, int $operator_id = 0, string $operator_note = ''): bool { global $wpdb; $record = $this->get($id); if (!$record) { return false; } $valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_COMPLETED, self::STATUS_CANCELLED]; if (!in_array($new_status, $valid, true)) return false; if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) { return false; } $update = [ 'status' => $new_status, 'updated_at' => current_time('mysql'), ]; if ($operator_id > 0) { $update['operator_id'] = $operator_id; } if ($operator_note !== '') { $update['operator_note'] = sanitize_textarea_field($operator_note); } if (in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) { $update['resolved_at'] = current_time('mysql'); } $updated = (bool) $wpdb->update($this->table(), $update, ['id' => $id]); if ($updated) { $note = $operator_note !== '' ? $operator_note : sprintf(__('售后状态更新为:%s', 'aksrt'), $this->get_status_label($new_status)); $this->sync_order_status($record, $new_status, $note); } return $updated; } /** * 获取统计 */ public function get_stats(): array { global $wpdb; $table = $this->table(); $counts = $wpdb->get_results( "SELECT status, COUNT(*) as cnt FROM {$table} GROUP BY status", ARRAY_A ); $counts = array_column($counts, 'cnt', 'status'); $stats = [ 'total' => 0, 'pending' => 0, 'processing' => 0, 'approved' => 0, 'rejected' => 0, 'completed' => 0, 'cancelled' => 0, ]; foreach ($counts as $status => $cnt) { if (isset($stats[$status])) { $stats[$status] = (int) $cnt; } $stats['total'] += (int) $cnt; } return $stats; } private function decorate_record(array $record): array { $record['id'] = (int) ($record['id'] ?? 0); $record['user_id'] = (int) ($record['user_id'] ?? 0); $record['operator_id'] = isset($record['operator_id']) ? (int) $record['operator_id'] : 0; $record['amount'] = (float) ($record['amount'] ?? 0); $record['status_label'] = $this->get_status_label((string) ($record['status'] ?? '')); $record['type_label'] = $this->get_type_label((string) ($record['type'] ?? '')); $record['operator'] = $this->get_user_summary((int) ($record['operator_id'] ?? 0)); $record['applicant'] = $this->get_user_summary((int) ($record['user_id'] ?? 0)); $record['stage'] = $this->build_stage_payload($record); return $record; } private function build_stage_payload(array $record): array { $status = (string) ($record['status'] ?? ''); if (in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) { return [ 'key' => 'review', 'label' => __('审核处理中', 'aksrt'), ]; } if ($status === self::STATUS_APPROVED) { return [ 'key' => 'approved', 'label' => __('已同意,待完成', 'aksrt'), ]; } if ($status === self::STATUS_COMPLETED) { return [ 'key' => 'completed', 'label' => __('售后完成', 'aksrt'), ]; } if ($status === self::STATUS_REJECTED) { return [ 'key' => 'rejected', 'label' => __('售后拒绝', 'aksrt'), ]; } return [ 'key' => 'cancelled', 'label' => __('已取消', 'aksrt'), ]; } private function get_status_label(string $status): string { foreach (self::get_status_options() as $item) { if (($item['value'] ?? '') === $status) { return (string) ($item['label'] ?? $status); } } return $status; } private function get_type_label(string $type): string { foreach (self::get_type_options() as $item) { if (($item['value'] ?? '') === $type) { return (string) ($item['label'] ?? $type); } } return $type; } private function get_user_summary(int $user_id): array { if ($user_id <= 0) { return []; } $user = get_userdata($user_id); if (!$user) { return []; } return [ 'id' => $user_id, 'display_name' => (string) $user->display_name, 'user_login' => (string) $user->user_login, 'user_email' => (string) $user->user_email, ]; } /** * @return array */ private function map_stage_to_statuses(string $stage): array { $map = [ 'review' => [self::STATUS_PENDING, self::STATUS_PROCESSING], 'approved' => [self::STATUS_APPROVED], 'completed' => [self::STATUS_COMPLETED], 'rejected' => [self::STATUS_REJECTED], 'cancelled' => [self::STATUS_CANCELLED], ]; return $map[$stage] ?? []; } private function can_transition_status(string $current_status, string $new_status): bool { $map = [ self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED], self::STATUS_PROCESSING => [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED], self::STATUS_APPROVED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED], self::STATUS_REJECTED => [], self::STATUS_COMPLETED => [], self::STATUS_CANCELLED => [], ]; return in_array($new_status, $map[$current_status] ?? [], true); } private function build_summary_item(array $record): array { return [ 'id' => (int) ($record['id'] ?? 0), 'type' => (string) ($record['type'] ?? ''), 'type_label' => (string) ($record['type_label'] ?? ''), 'status' => (string) ($record['status'] ?? ''), 'status_label' => (string) ($record['status_label'] ?? ''), 'stage' => is_array($record['stage'] ?? null) ? $record['stage'] : [], 'amount' => (float) ($record['amount'] ?? 0), 'reason' => (string) ($record['reason'] ?? ''), 'created_at' => (string) ($record['created_at'] ?? ''), 'updated_at' => (string) ($record['updated_at'] ?? ''), 'resolved_at' => (string) ($record['resolved_at'] ?? ''), ]; } /** * R69: Execute actual refund — credit back user balance / points. */ private function execute_refund(?array $order, float $refund_amount): void { if (!$order || $refund_amount <= 0) { return; } $user_id = (int) ($order['user_id'] ?? 0); if ($user_id <= 0) { return; } $payment_method = (string) ($order['payment_method'] ?? 'balance'); $order_number = (string) ($order['order_number'] ?? ''); // Balance refund: credit back via AKSRT_Credits if ($payment_method === 'balance' && class_exists('AKSRT_Credits')) { AKSRT_Credits::add( $user_id, (int) round($refund_amount), sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number), ['order_number' => $order_number, 'type' => 'refund'], sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2)) ); } // Deduct sales_volume for refunded items $meta = json_decode((string) ($order['note'] ?? ''), true); $items = is_array($meta) ? ($meta['items'] ?? []) : []; if (empty($items) && !empty($order['product_id'])) { $items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]]; } foreach ($items as $item) { $pid = (int) ($item['product_id'] ?? 0); $qty = max(1, (int) ($item['quantity'] ?? 1)); if ($pid <= 0) { continue; } $sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true); update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty)); } /** * Fires after refund execution. * * @param string $order_number * @param int $user_id * @param float $refund_amount * @param string $payment_method */ do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method); } private function sync_order_status(array $record, string $new_status, string $note = ''): void { if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) { return; } $type = (string) ($record['type'] ?? ''); $is_refund_type = in_array($type, [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_INSURED_PRICE], true); if (!$is_refund_type) { return; } $order_number = sanitize_text_field((string) ($record['order_number'] ?? '')); if ($order_number === '') { return; } $repository = AKSRT_Order_Repository::get_instance(); if (in_array($new_status, [self::STATUS_PROCESSING, self::STATUS_APPROVED], true)) { $repository->update_status($order_number, AKSRT_Order::STATUS_REFUNDING, [ 'note' => $note, ]); return; } if ($new_status === self::STATUS_COMPLETED) { $order = $repository->get($order_number); if (($order['status'] ?? '') !== AKSRT_Order::STATUS_REFUNDING) { $repository->update_status($order_number, AKSRT_Order::STATUS_REFUNDING, [ 'note' => $note, ]); } $this->execute_refund($order, (float) ($record['amount'] ?? 0)); $repository->update_status($order_number, AKSRT_Order::STATUS_REFUNDED, [ 'note' => $note, ]); } } /* ============================================================ * Merchant Actions (§6.8) * ============================================================ */ public function merchant_approve(int $ticket_id, int $operator_id, string $note = ''): bool { global $wpdb; $record = $this->get_by_id($ticket_id); if (!$record || $record['status'] !== self::STATUS_PENDING) { return false; } $type = (string) $record['type']; $needs_return = in_array($type, [self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPLACEMENT], true); $new_status = $needs_return ? self::STATUS_PROCESSING : self::STATUS_APPROVED; $progress = $needs_return ? self::PROGRESS_WAITING_USER_SHIP : self::PROGRESS_WAITING_MERCHANT; $wpdb->update($this->table(), [ 'status' => $new_status, 'operator_id' => $operator_id, 'operator_note' => sanitize_textarea_field($note), 'updated_at' => current_time('mysql'), ], ['id' => $ticket_id]); update_post_meta($ticket_id, '_aksrt_as_progress', $progress); $this->sync_order_status($record, $new_status, $note ?: __('商家已同意售后', 'aksrt')); return true; } public function merchant_reject(int $ticket_id, int $operator_id, string $note = ''): bool { global $wpdb; $record = $this->get_by_id($ticket_id); if (!$record || $record['status'] !== self::STATUS_PENDING) { return false; } $wpdb->update($this->table(), [ 'status' => self::STATUS_REJECTED, 'operator_id' => $operator_id, 'operator_note' => sanitize_textarea_field($note), 'updated_at' => current_time('mysql'), ], ['id' => $ticket_id]); update_post_meta($ticket_id, '_aksrt_as_progress', 0); return true; } public function user_submit_return_shipping(int $ticket_id, int $user_id, string $carrier, string $tracking_no): bool { global $wpdb; $record = $this->get_by_id($ticket_id); if (!$record || (int) ($record['user_id'] ?? 0) !== $user_id) { return false; } if ($record['status'] !== self::STATUS_PROCESSING) { return false; } $return_data = wp_json_encode([ 'carrier' => sanitize_text_field($carrier), 'tracking_no' => sanitize_text_field($tracking_no), 'submitted_at' => current_time('mysql'), ], JSON_UNESCAPED_UNICODE); $wpdb->update($this->table(), [ 'note' => $return_data, 'updated_at' => current_time('mysql'), ], ['id' => $ticket_id]); update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_WAITING_MERCHANT); return true; } public function merchant_confirm_return(int $ticket_id, int $operator_id, string $note = ''): bool { global $wpdb; $record = $this->get_by_id($ticket_id); if (!$record || $record['status'] !== self::STATUS_PROCESSING) { return false; } $type = (string) $record['type']; if (in_array($type, [self::TYPE_EXCHANGE, self::TYPE_REPLACEMENT], true)) { update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_WAITING_USER_RECEIVE); $wpdb->update($this->table(), [ 'operator_note' => sanitize_textarea_field($note), 'updated_at' => current_time('mysql'), ], ['id' => $ticket_id]); return true; } $wpdb->update($this->table(), [ 'status' => self::STATUS_COMPLETED, 'operator_id' => $operator_id, 'operator_note' => sanitize_textarea_field($note), 'updated_at' => current_time('mysql'), 'resolved_at' => current_time('mysql'), ], ['id' => $ticket_id]); update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_DONE); $this->sync_order_status($record, self::STATUS_COMPLETED, $note ?: __('售后已完成', 'aksrt')); return true; } /* ============================================================ * Shipping status window + type constraints (§6.8) * ============================================================ */ public function can_apply_after_sale(array $order, string $type): bool { $status = (string) ($order['status'] ?? ''); if (!in_array($status, [ AKSRT_Order::STATUS_PAID, AKSRT_Order::STATUS_DELIVERED, AKSRT_Order::STATUS_COMPLETED, AKSRT_Order::STATUS_PENDING_REVIEW, ], true)) { return false; } $shipping_status = (int) ($order['shipping_status'] ?? 0); if ($type === self::TYPE_REFUND) { return true; } if (in_array($type, [self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPLACEMENT], true)) { return $shipping_status >= AKSRT_Domain_Model::SHIPPING_STATUS_SHIPPED; } if ($type === self::TYPE_WARRANTY) { return $shipping_status >= AKSRT_Domain_Model::SHIPPING_STATUS_COMPLETED; } return true; } public function get_type_timeouts(): array { $shop = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_shop_settings() : []; $timing = $shop['timing'] ?? []; return [ self::TYPE_REFUND => (int) ($timing['refund_max_day'] ?? 7), self::TYPE_REFUND_RETURN => (int) ($timing['refund_return_max_day'] ?? 7), self::TYPE_EXCHANGE => (int) ($timing['exchange_max_day'] ?? 7), self::TYPE_REPLACEMENT => (int) ($timing['replacement_max_day'] ?? 7), self::TYPE_WARRANTY => (int) ($timing['warranty_max_day'] ?? 30), self::TYPE_INSURED_PRICE => (int) ($timing['insured_price_max_day'] ?? 15), ]; } /* ============================================================ * Auto-cancel Cron (§6.8) * ============================================================ */ public function run_auto_cancel_cron(): void { global $wpdb; $table = $this->table(); $timeouts = $this->get_type_timeouts(); foreach ($timeouts as $type => $max_days) { if ($max_days <= 0) continue; $cutoff = date('Y-m-d H:i:s', strtotime("-{$max_days} days")); $stale = $wpdb->get_results($wpdb->prepare( "SELECT id, order_number, type, status FROM {$table} WHERE type = %s AND status IN (%s, %s) AND created_at < %s LIMIT 200", $type, self::STATUS_PENDING, self::STATUS_PROCESSING, $cutoff ), ARRAY_A); foreach ($stale as $record) { $wpdb->update($table, [ 'status' => self::STATUS_CANCELLED, 'updated_at' => current_time('mysql'), 'operator_note' => sprintf(__('系统自动取消(超过 %d 天未处理)', 'aksrt'), $max_days), ], ['id' => (int) $record['id']]); } } } private function get_by_id(int $id): ?array { global $wpdb; $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->table()} WHERE id = %d", $id ), ARRAY_A); return $row ?: null; } public static function get_valid_types(): array { return [ self::TYPE_REFUND => __('仅退款', 'aksrt'), self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'), self::TYPE_EXCHANGE => __('换货', 'aksrt'), self::TYPE_REPLACEMENT => __('补发', 'aksrt'), self::TYPE_WARRANTY => __('保修', 'aksrt'), self::TYPE_INSURED_PRICE => __('保价', 'aksrt'), ]; } public static function get_progress_labels(): array { return [ self::PROGRESS_WAITING_USER_SHIP => __('等待用户发货', 'aksrt'), self::PROGRESS_WAITING_MERCHANT => __('等待商家处理', 'aksrt'), self::PROGRESS_WAITING_USER_RECEIVE => __('等待用户收货', 'aksrt'), self::PROGRESS_DONE => __('已完成', 'aksrt'), ]; } }