Move ~94 root class shells and root *-traits into domain directories (core, auth, user, credits, content, payment/gateways, forum, shop, sms, ai, messages, extensions, widgets, settings, admin). Update bootstrap require paths, co-locate traits with owners, fix forum/user relative requires, drop duplicate shop controller requires, and wire home-modules load. Path-only; class names and init order unchanged.
812 lines
29 KiB
PHP
812 lines
29 KiB
PHP
<?php
|
|
/**
|
|
* AKSRT After Sale — 售后服务
|
|
*
|
|
* 管理退款、换货等售后工单。
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class AKSRT_After_Sale {
|
|
|
|
private static $instance = null;
|
|
|
|
const TYPE_REFUND = 'refund';
|
|
const TYPE_REFUND_RETURN = 'refund_return';
|
|
const TYPE_EXCHANGE = 'exchange';
|
|
const TYPE_REPAIR = 'repair';
|
|
const TYPE_WARRANTY = 'warranty';
|
|
const TYPE_INSURED_PRICE = 'insured_price';
|
|
const TYPE_REPLACEMENT = 'replacement';
|
|
|
|
const STATUS_PENDING = 'pending';
|
|
const STATUS_PROCESSING = 'processing';
|
|
const STATUS_APPROVED = 'approved';
|
|
const STATUS_REJECTED = 'rejected';
|
|
const STATUS_COMPLETED = 'completed';
|
|
const STATUS_CANCELLED = 'cancelled';
|
|
|
|
const PROGRESS_WAITING_USER_SHIP = 1;
|
|
const PROGRESS_WAITING_MERCHANT = 2;
|
|
const PROGRESS_WAITING_USER_RECEIVE = 3;
|
|
const PROGRESS_DONE = 4;
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
private function table(): string {
|
|
return AKSRT_DB_Migrator::table('after_sale');
|
|
}
|
|
|
|
/**
|
|
* 创建售后工单
|
|
*/
|
|
public function create(array $data): int {
|
|
global $wpdb;
|
|
|
|
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
|
|
$user_id = (int) ($data['user_id'] ?? 0);
|
|
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_REFUND));
|
|
$reason = sanitize_textarea_field((string) ($data['reason'] ?? ''));
|
|
$amount = (float) ($data['amount'] ?? 0);
|
|
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
|
|
|
if ($order_number === '' || $user_id <= 0 || $reason === '') {
|
|
return 0;
|
|
}
|
|
|
|
$valid_types = [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPAIR, self::TYPE_WARRANTY, self::TYPE_INSURED_PRICE, self::TYPE_REPLACEMENT];
|
|
if (!in_array($type, $valid_types, true)) {
|
|
$type = self::TYPE_REFUND;
|
|
}
|
|
|
|
$result = $wpdb->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<int, string> $order_numbers
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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: prefer cash wallet, fall back to points.
|
|
if ($payment_method === 'balance') {
|
|
if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) {
|
|
AKSRT_Balance::add(
|
|
$user_id,
|
|
(float) $refund_amount,
|
|
'order_refund',
|
|
['type' => 'order', 'order' => $order_number],
|
|
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
|
|
);
|
|
} elseif (class_exists('AKSRT_Credits')) {
|
|
AKSRT_Credits::add(
|
|
$user_id,
|
|
(int) round($refund_amount),
|
|
'order_refund',
|
|
['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'),
|
|
];
|
|
}
|
|
}
|