431 lines
15 KiB
PHP
431 lines
15 KiB
PHP
<?php
|
|
defined('ABSPATH') || exit;
|
|
|
|
trait AKSRT_Shop_Part_2 {
|
|
|
|
|
|
public function save_variants(int $product_id, array $variants): array {
|
|
return $this->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);
|
|
}
|
|
}
|