product_service()->save_variants($product_id, $variants); } public function delete_variant(int $variant_id): bool { return $this->product_service()->delete_variant($variant_id); } public function has_variants(int $product_id): bool { return $this->product_service()->has_variants($product_id); } /** * Get the price range for a product (considering variants). */ public function get_price_range(int $product_id): array { $base_price = (float) get_post_meta($product_id, '_aksrt_price', true); $variants = $this->get_variants($product_id); if (empty($variants)) { return ['min' => $base_price, 'max' => $base_price, 'has_variants' => false]; } $prices = array_column($variants, 'price'); $prices[] = $base_price; $prices = array_filter($prices, fn($p) => $p > 0); if (empty($prices)) { return ['min' => 0, 'max' => 0, 'has_variants' => true]; } return [ 'min' => min($prices), 'max' => max($prices), 'has_variants' => true, ]; } // ── R68: Fulfillment hook (§6.5) ── /** * aksrt_order_paid listener: deduct stock + increment sales. * Registered via AKSRT_Setup::init(). */ public function handle_order_paid(string $order_number, array $order): void { if ($this->order_item_table() !== '') { $items = $this->get_order_items($order_number); if (!empty($items)) { $this->handle_order_paid_enhanced($order_number, $order); return; } } $note_raw = (string) ($order['note'] ?? ''); $meta = json_decode($note_raw, true); if (!is_array($meta)) { $meta = []; } $items = $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; } // Deduct stock (skip if unlimited, i.e. stock <= -1) $stock = (int) get_post_meta($pid, '_aksrt_stock', true); if ($stock > 0) { $new_stock = max(0, $stock - $qty); update_post_meta($pid, '_aksrt_stock', $new_stock); } // Increment sales_volume $sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true); update_post_meta($pid, '_aksrt_sales_volume', $sales + $qty); } /** * Fires after shop fulfillment (stock + sales). * * @param string $order_number * @param array $order * @param array $items Normalized item list */ do_action('aksrt_shop_fulfillment_done', $order_number, $order, $items); } // ── R68: Confirm receipt (§6.5) ── /** * User confirms receipt: delivered -> completed. */ public function confirm_receipt(int $user_id, string $order_number): array { if ($user_id <= 0 || $order_number === '' || !class_exists('AKSRT_Order_Repository')) { return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; } $repo = AKSRT_Order_Repository::get_instance(); $order = $repo->get_user_order($user_id, $order_number); if (!$order) { return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; } $status = (string) ($order['status'] ?? ''); if ($status !== AKSRT_Order::STATUS_DELIVERED) { return ['success' => false, 'message' => __('当前订单状态不可确认收货。', 'aksrt')]; } $repo->update_status($order_number, AKSRT_Order::STATUS_COMPLETED, [ 'note' => __('用户确认收货。', 'aksrt'), ]); return [ 'success' => true, 'data' => [ 'order' => $this->get_order_detail_view($user_id, $order_number), ], ]; } /** * Auto-confirm cron: complete orders delivered > receipt_max_day ago. */ public function run_auto_confirm_cron(): void { if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Settings')) { return; } $shop = AKSRT_Settings::get_shop_settings(); $max_day = (int) ($shop['timing']['receipt_max_day'] ?? 15); if ($max_day <= 0) { $max_day = 15; } $cutoff = date('Y-m-d H:i:s', strtotime("-{$max_day} days")); $repo = AKSRT_Order_Repository::get_instance(); $orders = $repo->get_orders_admin(['status' => AKSRT_Order::STATUS_DELIVERED, 'limit' => 500]); foreach ($orders as $order) { $delivered_at = (string) ($order['delivered_at'] ?? ''); if ($delivered_at === '' || $delivered_at > $cutoff) { continue; } $repo->update_status((string) $order['order_number'], AKSRT_Order::STATUS_COMPLETED, [ 'note' => sprintf(__('系统自动确认收货(超过 %d 天)。', 'aksrt'), $max_day), ]); } } // ── R68: Order CSV export (§6.5) ── /** * Export orders as CSV rows. Admin-only. */ public function export_orders_csv(array $args = []): array { if (!class_exists('AKSRT_Order_Repository')) { return ['csv' => '', 'count' => 0]; } $status = sanitize_key((string) ($args['status'] ?? '')); $date_from = sanitize_text_field((string) ($args['date_from'] ?? '')); $date_to = sanitize_text_field((string) ($args['date_to'] ?? '')); $limit = max(1, min(5000, (int) ($args['limit'] ?? 1000))); $repo = AKSRT_Order_Repository::get_instance(); $orders = $repo->get_orders_admin(['status' => $status, 'limit' => $limit]); if ($date_from !== '') { $orders = array_filter($orders, function ($o) use ($date_from) { return ($o['created_at'] ?? '') >= $date_from . ' 00:00:00'; }); } if ($date_to !== '') { $orders = array_filter($orders, function ($o) use ($date_to) { return ($o['created_at'] ?? '') <= $date_to . ' 23:59:59'; }); } $header = ['order_number', 'user_id', 'product_type', 'product_id', 'amount', 'currency', 'status', 'payment_method', 'created_at', 'paid_at']; $rows = [implode(',', $header)]; foreach ($orders as $o) { $rows[] = implode(',', [ (string) ($o['order_number'] ?? ''), (int) ($o['user_id'] ?? 0), (string) ($o['product_type'] ?? ''), (int) ($o['product_id'] ?? 0), (float) ($o['amount'] ?? 0), (string) ($o['currency'] ?? 'CNY'), (string) ($o['status'] ?? ''), (string) ($o['payment_method'] ?? ''), (string) ($o['created_at'] ?? ''), (string) ($o['paid_at'] ?? ''), ]); } $bom = "\xEF\xBB\xBF"; $csv = $bom . implode("\r\n", $rows); return ['csv' => $csv, 'count' => count($orders)]; } // ── R68: Configurable shipping/payment (§6.11) ── /** * Get payment method options from settings, falling back to hardcoded defaults. */ public function get_payment_method_options_configurable(): array { if (!class_exists('AKSRT_Settings')) { return $this->get_payment_method_options(); } $shop = AKSRT_Settings::get_shop_settings(); $enabled = (string) ($shop['product_defaults']['payment_methods'] ?? ''); if ($enabled === '') { return $this->get_payment_method_options(); } $all = $this->get_payment_method_options(); $keys = array_map('trim', explode(',', $enabled)); $result = []; foreach ($keys as $k) { $k = sanitize_key($k); if (isset($all[$k])) { $result[$k] = $all[$k]; } } return $result ?: $this->get_payment_method_options(); } /** * Get shipping method options from settings, falling back to hardcoded defaults. */ public function get_shipping_method_options_configurable(): array { if (!class_exists('AKSRT_Settings')) { return $this->get_shipping_method_options(); } $shop = AKSRT_Settings::get_shop_settings(); $enabled = (string) ($shop['product_defaults']['shipping_methods'] ?? ''); $fee_override = (float) ($shop['product_defaults']['shipping_fee_fixed'] ?? 0); if ($enabled === '' && $fee_override <= 0) { return $this->get_shipping_method_options(); } $all = $this->get_shipping_method_options(); if ($enabled !== '') { $keys = array_map('trim', explode(',', $enabled)); $filtered = []; foreach ($keys as $k) { $k = sanitize_key($k); if (isset($all[$k])) { $filtered[$k] = $all[$k]; } } $all = $filtered ?: $all; } if ($fee_override > 0) { foreach ($all as $k => &$v) { if ($k !== 'download') { $v['fee'] = $fee_override; } } unset($v); } return $all; } /** * Get after-sale reason options from settings. */ public function get_after_sale_reason_options(): array { if (!class_exists('AKSRT_Settings')) { return ['refund' => [], 'return' => []]; } $shop = AKSRT_Settings::get_shop_settings(); $refund_raw = (string) ($shop['timing']['after_sale_reason_refund'] ?? ''); $return_raw = (string) ($shop['timing']['after_sale_reason_return'] ?? ''); return [ 'refund' => $this->parse_reason_lines($refund_raw), 'return' => $this->parse_reason_lines($return_raw), ]; } /** * Parse newline-separated reason text into indexed array. */ private 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; } /* ============================================================ * Product Options (§6.2 选项组机制) * * Storage: post_meta '_aksrt_product_options' = [ * [ 'name' => '颜色', 'view_mode' => 'list', 'values' => [ * [ 'label' => '红色', 'price_change' => 0, 'image' => '' ], * [ 'label' => '蓝色', 'price_change' => 10, 'image' => 'url' ], * ]], * ] * * options_key = "红色|M" (pipe-delimited selected labels per group) * ============================================================ */ public function get_product_options(int $product_id): array { $raw = get_post_meta($product_id, '_aksrt_product_options', true); if (!is_array($raw) || empty($raw)) { return []; } return array_values(array_filter(array_map([$this, 'sanitize_option_group'], $raw))); } public function save_product_options(int $product_id, array $groups): void { $sanitized = array_values(array_filter(array_map([$this, 'sanitize_option_group'], $groups))); update_post_meta($product_id, '_aksrt_product_options', $sanitized); update_post_meta($product_id, '_aksrt_has_options', count($sanitized) > 0 ? '1' : '0'); } public function has_options(int $product_id): bool { return get_post_meta($product_id, '_aksrt_has_options', true) === '1'; } private function sanitize_option_group($group): array { if (!is_array($group)) return []; $name = sanitize_text_field((string) ($group['name'] ?? '')); if ($name === '') return []; $view_mode_raw = $group['view_mode'] ?? ''; $view_mode = in_array($view_mode_raw, ['list', 'img'], true) ? $view_mode_raw : 'list'; $values = []; foreach ((array) ($group['values'] ?? []) as $v) { $label = sanitize_text_field((string) ($v['label'] ?? '')); if ($label === '') continue; $values[] = [ 'label' => $label, 'price_change' => round((float) ($v['price_change'] ?? 0), 2), 'image' => esc_url_raw((string) ($v['image'] ?? '')), ]; } if (empty($values)) return []; return ['name' => $name, 'view_mode' => $view_mode, 'values' => $values]; } public function build_options_key(array $selections): string { return implode('|', array_map('sanitize_text_field', $selections)); } public function resolve_option_price(int $product_id, string $options_key): float { $base = (float) get_post_meta($product_id, '_aksrt_price', true); $start = (float) get_post_meta($product_id, '_aksrt_start_price', true); if ($start > 0) { $base = $start; } if ($options_key === '' || $options_key === '_default') { return $base; } $groups = $this->get_product_options($product_id); $selections = explode('|', $options_key); foreach ($groups as $i => $group) { $selected_label = $selections[$i] ?? ''; foreach ($group['values'] as $val) { if ($val['label'] === $selected_label) { $base += $val['price_change']; break; } } } return max(0, round($base, 2)); } public function resolve_option_labels(int $product_id, string $options_key): string { if ($options_key === '' || $options_key === '_default') { return ''; } $groups = $this->get_product_options($product_id); $selections = explode('|', $options_key); $parts = []; foreach ($groups as $i => $group) { $sel = $selections[$i] ?? ''; if ($sel !== '') { $parts[] = $group['name'] . ':' . $sel; } } return implode(', ', $parts); } public function resolve_option_stock(int $product_id, string $options_key): int { $stock_type = (string) get_post_meta($product_id, '_aksrt_stock_type', true); if ($stock_type === 'opts' && $options_key !== '' && $options_key !== '_default') { $stock_matrix = get_post_meta($product_id, '_aksrt_stock_opts', true); if (is_array($stock_matrix) && isset($stock_matrix[$options_key])) { return (int) $stock_matrix[$options_key]; } return 0; } return (int) get_post_meta($product_id, '_aksrt_stock', true); } }