feat(admin): rebuild the payment center overview with ECharts analytics

- Overview page: realtime today vs. same time yesterday, 6 KPI cards with
  period-over-period deltas, trend (paid / net / refund curves + order count
  line), category curves, payment method donut, top 10 paid content,
  conversion / close / refund rates, todos and shortcuts
- Date presets, custom range and category filter via
  GET aksrt/v1/admin/pay-overview (manage_options + wp_rest nonce)
- Metrics: paid amount by paid date including later-refunded orders, refunds
  by refund date, net = paid - refund; CNY only, points and recharge excluded,
  foreign-currency orders listed separately; integer cents internally
- Daily records cached per month in transients (today/yesterday live),
  invalidated on order status change and on purge
- ECharts 6.1.0 subset bundled to assets/js/runtime/libs/echarts-pay.min.js
  (npm run build:pay-charts), loaded only on the overview page
- Split payment-attempt methods out of OrderRepositoryCore into
  OrderRepositoryAttempts; remove dead get_stats(), cash_decimal(),
  Order::get_stats() and the old .aksrt-native-links styles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-11-01 00:22:44 +08:00
1 parent d98f8d7975
commit 9805a371d8
19 files changed
+2203 -405

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* 订单与支付渠道交易号(out_trade_no)的映射:meta 合并、按交易号反查订单、重复支付时分配新交易号。
*
* 作为 flat sibling trait 被 OrderRepository 使用(PSR-4:trait 必须与宿主类同目录)。
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
trait OrderRepositoryAttempts {
/**
* Merge keys into the order note JSON payload (meta_payload).
*
* @param array<string,mixed> $patch
*/
public function merge_meta(string $order_number, array $patch): bool {
global $wpdb;
$order = $this->get($order_number);
if (!$order) {
return false;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
foreach ($patch as $key => $value) {
$meta[(string) $key] = $value;
}
$encoded = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return false;
}
$updated = $wpdb->update(
$this->table(),
['note' => $encoded],
['order_number' => $order_number],
['%s'],
['%s']
);
return $updated !== false;
}
/**
* Resolve a local order from a provider out_trade_no (base or attempt-suffixed).
*
* The authoritative mapping is the order's own meta (provider_out_trade_no /
* provider_out_trade_nos); the prefix heuristic is only a fallback and must
* never be the sole resolver, otherwise a truncated attempt number would
* strand a real payment.
*/
public function resolve_by_out_trade_no(string $out_trade_no): ?array {
global $wpdb;
$out_trade_no = sanitize_text_field($out_trade_no);
if ($out_trade_no === '') {
return null;
}
$direct = $this->get($out_trade_no);
if ($direct) {
return $direct;
}
// Authoritative: the attempt number was recorded in the order note JSON.
$needle = '"provider_out_trade_no":"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE note LIKE %s ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like($needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->expire_order_if_due($this->normalize_row($row));
}
// Superseded attempts stay in the history array.
$history_needle = '"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()}
WHERE note LIKE %s AND note LIKE %s
ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like('"provider_out_trade_nos"') . '%',
'%' . $wpdb->esc_like($history_needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->expire_order_if_due($this->normalize_row($row));
}
// Last resort: {order_number}P{n} prefix heuristic.
if (preg_match('/^(.+)P([0-9]{1,3})$/', $out_trade_no, $m)) {
$base = $this->get($m[1]);
if ($base) {
return $base;
}
}
return null;
}
/**
* Provider out_trade_no currently bound to this order (falls back to order_number).
*
* @param array<string,mixed> $order
*/
public static function provider_out_trade_no(array $order): string {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$out = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
if ($out !== '') {
return $out;
}
return sanitize_text_field((string) ($order['order_number'] ?? ''));
}
/**
* Allocate a new provider out_trade_no for a payment attempt.
* First attempt uses bare order_number; later attempts append Pn (WeChat limit 32).
*
* @return array{out_trade_no:string,attempt:int,previous:string}|null
*/
public function allocate_payment_attempt(string $order_number): ?array {
$order = $this->get($order_number);
if (!$order) {
return null;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$previous = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
$history = is_array($meta['provider_out_trade_nos'] ?? null) ? $meta['provider_out_trade_nos'] : [];
// First provider submission keeps the bare order number.
// Re-pay (provider_out_trade_no already set) always mints a new suffix so
// WeChat/Alipay/Epay accept the order when amount/subject/notify changed.
if ($previous === '') {
$attempt = 1;
$out = $order_number;
} else {
$attempt = max(1, (int) ($meta['pay_attempt'] ?? 1)) + 1;
$suffix = 'P' . $attempt;
$base = $order_number;
if (strlen($base) + strlen($suffix) > 32) {
$base = substr($base, 0, 32 - strlen($suffix));
}
$out = $base . $suffix;
if ($previous !== '' && !in_array($previous, $history, true)) {
$history[] = $previous;
}
}
if (!in_array($out, $history, true)) {
$history[] = $out;
}
if (count($history) > 20) {
$history = array_slice($history, -20);
}
$ok = $this->merge_meta($order_number, [
'pay_attempt' => $attempt,
'provider_out_trade_no' => $out,
'provider_out_trade_nos' => array_values($history),
]);
if (!$ok) {
return null;
}
return [
'out_trade_no' => $out,
'attempt' => $attempt,
'previous' => $previous,
];
}
}