Files
wordpress-theme-aksrt/src/Payment/OrderRepositoryAdmin.php
T

681 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
trait OrderRepositoryAdmin {
/**
* Build a stable admin-facing order context payload for workbench pages.
*/
public function get_admin_order_context(string $order_number): array {
$order_number = sanitize_text_field($order_number);
$contexts = $this->get_admin_order_context_map([$order_number]);
return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number);
}
/**
* Build admin-facing order context payloads in bulk to avoid per-row order queries.
*
* @param array<int, string> $order_numbers
* @return array<string, array<string, mixed>>
*/
public function get_admin_order_context_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 [];
}
$contexts = [];
foreach ($order_numbers as $order_number) {
$contexts[$order_number] = $this->get_empty_admin_order_context($order_number);
}
$placeholders = implode(', ', array_fill(0, count($order_numbers), '%s'));
$sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})";
$rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A);
if (empty($rows)) {
return $contexts;
}
$orders = [];
foreach ((array) $rows as $row) {
$order = $this->normalize_row($row);
$orders[(string) ($order['order_number'] ?? '')] = $order;
}
$resolved_order_numbers = array_keys($orders);
foreach ($orders as $order_number => $order) {
$contexts[$order_number] = $this->build_admin_order_context($order, []);
}
return $contexts;
}
/**
* 更新订单状态(带状态机验证)
*/
/**
* Merge operator admin note into order note meta without destroying JSON payload.
*/
public function update_admin_note(string $order_number, string $admin_note): bool {
global $wpdb;
$order = $this->get($order_number);
if (!$order) {
return false;
}
$raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
$decoded = $raw !== '' ? json_decode($raw, true) : null;
$admin_note = sanitize_textarea_field($admin_note);
if (is_array($decoded)) {
$decoded['admin_note'] = $admin_note;
$new_note = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} elseif ($raw !== '') {
$new_note = wp_json_encode([
'customer_note' => $raw,
'admin_note' => $admin_note,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
$new_note = wp_json_encode([
'admin_note' => $admin_note,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$result = $wpdb->update(
$this->table(),
['note' => $new_note],
['order_number' => $order_number],
['%s'],
['%s']
);
return $result !== false;
}
/**
* 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配)
*/
public function get_orders_admin(array $args = [], string $search = ''): array {
global $wpdb;
$this->expire_pending_orders();
[$where, $params] = $this->build_admin_order_filters($args, $search);
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return array_map([$this, 'normalize_row'], (array) $rows);
}
/**
* 管理后台订单计数 — 与 get_orders_admin 共用过滤条件
*/
public function get_orders_count(array $args = [], string $search = ''): int {
global $wpdb;
$this->expire_pending_orders();
[$where, $params] = $this->build_admin_order_filters($args, $search);
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
if (!empty($params)) {
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
}
return (int) $wpdb->get_var($sql);
}
/**
* Aggregate paid content (resource) sales by product_id (post ID).
*
* @param array{
* product_type?: string,
* product_ids?: int[],
* status_in?: string[],
* limit?: int,
* offset?: int,
* orderby?: string,
* order?: string
* } $args
* @return array{items: list<array{product_id:int,product_type:string,sales_count:int,avg_amount:int|string,revenue:int|string}>, total: int}
*/
public function get_content_sales_summary(array $args = []): array {
global $wpdb;
$product_type = sanitize_key((string) ($args['product_type'] ?? \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE));
if ($product_type === '') {
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE;
}
$status_in = $args['status_in'] ?? \AKSRT\Core\DomainModel::paid_order_statuses();
if (!is_array($status_in) || empty($status_in)) {
$status_in = \AKSRT\Core\DomainModel::paid_order_statuses();
}
$status_in = array_values(array_filter(array_map('sanitize_key', $status_in)));
if (empty($status_in)) {
return ['items' => [], 'total' => 0];
}
$content_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO,
];
$legacy_pay_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
];
$where = ['product_id > 0'];
$params = [];
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($content_types), '%s')) . ')';
array_push($params, ...$content_types);
} elseif (isset($legacy_pay_types[$product_type])) {
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
} else {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
$where[] = "status IN ({$status_placeholders})";
foreach ($status_in as $st) {
$params[] = $st;
}
$product_ids = $args['product_ids'] ?? [];
if (is_array($product_ids) && !empty($product_ids)) {
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
return $id > 0;
})));
if (!empty($ids)) {
$id_ph = implode(', ', array_fill(0, count($ids), '%d'));
$where[] = "product_id IN ({$id_ph})";
foreach ($ids as $id) {
$params[] = $id;
}
}
}
$where_sql = 'WHERE ' . implode(' AND ', $where);
$table = $this->table();
$total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}";
$total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params));
$orderby_map = [
'sales_count' => 'sales_count',
'revenue' => 'revenue',
'avg_amount' => 'avg_amount',
'product_id' => 'product_id',
];
$orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count'));
$orderby_col = $orderby_map[$orderby_key] ?? 'sales_count';
$order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$list_sql = "SELECT product_id,
MIN(product_type) AS product_type,
currency,
COUNT(*) AS sales_count,
AVG(amount) AS avg_amount,
SUM(amount) AS revenue
FROM {$table}
{$where_sql}
GROUP BY product_id, currency
ORDER BY {$orderby_col} {$order_dir}
LIMIT %d OFFSET %d";
$list_params = $params;
$list_params[] = $limit;
$list_params[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
$items = [];
$aggregate = [];
foreach ((array) $rows as $row) {
$sales_count = max(1, (int) ($row['sales_count'] ?? 0));
$is_points = (string) ($row['currency'] ?? '') === 'points';
if ($is_points) {
$revenue = max(0, (int) ($row['revenue'] ?? 0));
$avg_amount = intdiv($revenue + intdiv($sales_count, 2), $sales_count);
} else {
try {
$revenue_minor = \AKSRT\Ledger\AssetAmount::balance_minor((string) ($row['revenue'] ?? '0'));
} catch (\InvalidArgumentException $e) {
$revenue_minor = 0;
}
$revenue = \AKSRT\Ledger\AssetAmount::decimal_from_minor($revenue_minor);
$avg_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor(
intdiv($revenue_minor + intdiv($sales_count, 2), $sales_count)
);
}
$pid = (int) ($row['product_id'] ?? 0);
if (!isset($aggregate[$pid])) {
$aggregate[$pid] = [
'product_id' => (int) ($row['product_id'] ?? 0),
'product_type' => $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE ? $product_type : (string) ($row['product_type'] ?? $product_type),
'sales_count' => 0,
'avg_amount' => 0,
'revenue_minor' => 0,
'currency' => (string) ($row['currency'] ?? 'CNY'),
];
}
$aggregate[$pid]['sales_count'] += (int) ($row['sales_count'] ?? 0);
if ((string) ($row['currency'] ?? '') === 'points') {
$aggregate[$pid]['revenue_minor'] += (int) $revenue;
} else {
$aggregate[$pid]['revenue_minor'] += \AKSRT\Ledger\AssetAmount::balance_minor((string) $revenue);
}
}
foreach ($aggregate as $item) {
$count = max(1, (int) $item['sales_count']);
$is_points = $item['currency'] === 'points';
$revenue = $is_points
? (int) $item['revenue_minor']
: \AKSRT\Ledger\AssetAmount::decimal_from_minor((int) $item['revenue_minor']);
$average_minor = intdiv((int) $item['revenue_minor'] + intdiv($count, 2), $count);
$items[] = [
'product_id' => (int) $item['product_id'],
'product_type' => (string) $item['product_type'],
'sales_count' => (int) $item['sales_count'],
'avg_amount' => $is_points ? $average_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($average_minor),
'revenue' => $revenue,
];
}
return [
'items' => $items,
'total' => $total,
];
}
/**
* Paid order totals for a set of product IDs (resource content).
*
* @param int[] $product_ids
* @return array<int, array{sales_count:int,avg_amount:int|string,revenue:int|string}>
*/
public function get_content_sales_map(array $product_ids, string $product_type = ''): array {
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
return $id > 0;
})));
if (empty($ids)) {
return [];
}
if ($product_type === '') {
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE;
}
$summary = $this->get_content_sales_summary([
'product_type' => $product_type,
'product_ids' => $ids,
'limit' => max(count($ids), 1),
'offset' => 0,
'orderby' => 'product_id',
'order' => 'ASC',
]);
$map = [];
foreach ($summary['items'] as $item) {
$pid = (int) ($item['product_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$map[$pid] = [
'sales_count' => (int) ($item['sales_count'] ?? 0),
'avg_amount' => $item['avg_amount'] ?? '0.00',
'revenue' => $item['revenue'] ?? '0.00',
];
}
return $map;
}
/**
* Shared WHERE builder for admin order list/count.
*
* @return array{0: string[], 1: array<int, mixed>}
*/
private function build_admin_order_filters(array $args, string $search = ''): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
$valid_statuses = array_keys(\AKSRT\Core\DomainModel::order_status_labels());
if ($status !== '' && $status !== 'all' && in_array($status, $valid_statuses, true)) {
if ($status === 'paid') {
$where[] = "status IN (%s, 'refunding')";
$params[] = $status;
} else {
$where[] = 'status = %s';
$params[] = $status;
}
} else {
$where[] = "status IN (" . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ", 'refunding')";
array_push($params, ...$valid_statuses);
}
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
array_push($params, ...$types);
} else {
$legacy_pay_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
];
if (isset($legacy_pay_types[$product_type])) {
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
} else {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
}
}
$product_id = (int) ($args['product_id'] ?? 0);
if ($product_id > 0) {
$where[] = 'product_id = %d';
$params[] = $product_id;
}
$user_id = (int) ($args['user_id'] ?? 0);
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
if (array_key_exists('payment_methods', $args)) {
$payment_methods = array_values(array_unique(array_filter(array_map(
static fn($method): string => sanitize_key((string) $method),
(array) $args['payment_methods']
))));
if ($payment_methods === []) {
$where[] = '1 = 0';
} else {
$where[] = 'payment_method IN (' . implode(', ', array_fill(0, count($payment_methods), '%s')) . ')';
array_push($params, ...$payment_methods);
}
}
if ($search !== '') {
$user_table = $wpdb->users;
$user_match = "EXISTS (SELECT 1 FROM {$user_table} u WHERE u.ID = " . $this->table() . '.user_id AND (u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s))';
$post_table = $wpdb->posts;
$product_match = "EXISTS (SELECT 1 FROM {$post_table} p WHERE p.ID = " . $this->table() . '.product_id AND p.post_title LIKE %s)';
$meta_match = "note LIKE %s";
// Search matches the public order number, account, product title, and stored product label.
if (ctype_digit($search)) {
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$user_match} OR {$product_match} OR {$meta_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = (int) $search;
$params[] = (int) $search;
array_push($params, $like, $like, $like, $like, $like);
} else {
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$user_match} OR {$product_match} OR {$meta_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
array_push($params, $like, $like, $like, $like, $like);
}
}
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
return [$where, $params];
}
private function get_empty_admin_order_context(string $order_number): array {
return [
'exists' => false,
'summary' => [
'order_number' => $order_number,
'status' => '',
'status_label' => '',
'amount' => '0.00',
'currency_symbol' => '¥',
'created_at' => '',
'paid_at' => '',
],
'buyer' => [],
'address' => [],
'payment_method' => [],
'shipping_method' => [],
'shipping' => [
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => [],
'pricing' => [
'subtotal' => '0.00',
'shipping_fee' => '0.00',
'discount' => '0.00',
'amount' => '0.00',
],
'items' => [],
'customer_note' => '',
'raw_order' => null,
];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $related
* @return array<string, mixed>
*/
private function build_admin_order_context(array $order, array $related = []): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$user = get_userdata((int) ($order['user_id'] ?? 0));
$status = (string) ($order['status'] ?? '');
$address = is_array($meta['address'] ?? null) ? $meta['address'] : [];
$payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : [];
$shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : [];
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
$items = is_array($meta['items'] ?? null) ? $meta['items'] : [];
$currency = (string) ($order['currency'] ?? 'CNY');
$is_points = $currency === 'points';
$shipping_minor = $is_points ? max(0, (int) ($meta['shipping_fee'] ?? 0)) : $this->cash_minor((string) ($meta['shipping_fee'] ?? '0'));
$discount_minor = $is_points ? max(0, (int) ($meta['discount'] ?? 0)) : $this->cash_minor((string) ($meta['discount'] ?? '0'));
$amount_minor = $is_points ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($order['amount'] ?? '0'));
$fallback_minor = max(0, $amount_minor + $discount_minor - $shipping_minor);
$subtotal_minor = $this->calculate_order_subtotal_minor($items, $fallback_minor, $is_points);
$amount = $is_points ? $amount_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor);
$shipping_fee = $is_points ? $shipping_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($shipping_minor);
$discount = $is_points ? $discount_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($discount_minor);
$subtotal = $is_points ? $subtotal_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($subtotal_minor);
$address['full_text'] = $this->build_address_text($address);
return [
'exists' => true,
'summary' => [
'order_number' => (string) ($order['order_number'] ?? ''),
'status' => $status,
'status_label' => class_exists('AKSRT\\Payment\\Order') ? (Order::get_valid_statuses()[$status] ?? $status) : $status,
'amount' => $amount,
'currency_symbol' => $is_points ? __('积分', 'aksrt') : '¥',
'created_at' => (string) ($order['created_at'] ?? ''),
'paid_at' => (string) ($order['paid_at'] ?? ''),
'product_type' => (string) ($order['product_type'] ?? ''),
'product_id' => (int) ($order['product_id'] ?? 0),
],
'buyer' => [
'id' => (int) ($order['user_id'] ?? 0),
'display_name' => $user ? (string) $user->display_name : '',
'user_login' => $user ? (string) $user->user_login : '',
'user_email' => $user ? (string) $user->user_email : '',
],
'address' => $address,
'payment_method' => [
'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''),
'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''),
],
'shipping_method' => [
'value' => (string) ($shipping_method['value'] ?? ''),
'label' => (string) ($shipping_method['label'] ?? ''),
'fee' => $shipping_fee,
],
'shipping' => [
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => $coupon,
'pricing' => [
'subtotal' => $subtotal,
'shipping_fee' => $shipping_fee,
'discount' => $discount,
'amount' => $amount,
],
'items' => $items,
'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''),
'raw_order' => $order,
];
}
/**
* @param array<int, array<string, mixed>> $items
*/
private function calculate_order_subtotal_minor(array $items, int $fallback, bool $is_points): int {
if (empty($items)) {
return max(0, $fallback);
}
$subtotal = 0;
foreach ($items as $item) {
if (isset($item['line_total'])) {
$line_minor = $is_points
? max(0, (int) $item['line_total'])
: $this->cash_minor((string) $item['line_total']);
if ($subtotal > PHP_INT_MAX - $line_minor) {
return max(0, $fallback);
}
$subtotal += $line_minor;
continue;
}
$price_minor = $is_points
? max(0, (int) ($item['price'] ?? 0))
: $this->cash_minor((string) ($item['price'] ?? '0'));
$quantity = max(1, (int) ($item['quantity'] ?? 1));
if ($price_minor > intdiv(PHP_INT_MAX, $quantity)
|| $subtotal > PHP_INT_MAX - ($price_minor * $quantity)
) {
return max(0, $fallback);
}
$subtotal += $price_minor * $quantity;
}
if ($subtotal <= 0) {
return max(0, $fallback);
}
return $subtotal;
}
private function cash_minor(string $value): int {
try {
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
} catch (\InvalidArgumentException $e) {
return 0;
}
}
private function build_address_text(array $address): string {
$segments = [];
foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) {
$value = trim((string) ($address[$key] ?? ''));
if ($value !== '') {
$segments[] = $value;
}
}
if (!empty($address['full_text']) && is_string($address['full_text'])) {
return (string) $address['full_text'];
}
return implode(' / ', $segments);
}
}