update tmall refund parse

This commit is contained in:
2026-03-04 14:08:46 +08:00
parent cbe37d659b
commit 26e5c6806c
2 changed files with 650 additions and 8 deletions
@@ -4,15 +4,21 @@ declare(strict_types=1);
namespace App\Platform\Tmall\EntityParse;
use App\Constants\OrderStatus;
use App\Constants\OrderType;
use App\Constants\PaymentMethod;
use App\Constants\RefundStatus;
use App\Constants\RefundType;
use App\Platform\Tmall\Constants\RefundStatus as TmallRefundStatus;
use App\Model\Company;
use App\Model\Order;
use App\Model\OrderItem;
use App\Model\RefundItem;
use App\Model\Store;
use App\Entity\Parse\EntityParse;
use Carbon\Carbon;
use Hyperf\Collection\LazyCollection;
use Hyperf\DbConnection\Db;
use InvalidArgumentException;
/**
@@ -144,6 +150,15 @@ class Refund extends EntityParse
$tz = $this->getStore()->getTimezoneString();
$raw = \json_encode($record);
// 闪电退款优先使用 INSTANT_REFUND_WITHOUT_RETURN 类型
$refund_type_id = $this->getRefundTypeId($record);
if (isset($record['attribute']) && $record['attribute'] !== '') {
$attr = $this->parseRefundItemAttribute($record['attribute']);
if (!empty($attr) && $attr['clj_zero_second_refund']) {
$refund_type_id = RefundType::INSTANT_REFUND_WITHOUT_RETURN->value;
}
}
$items[] = [
'company_id' => $this->getCompany()->id,
'platform_id' => $this->getPlatform()->id,
@@ -152,7 +167,7 @@ class Refund extends EntityParse
'platform_parent_refund_id' => '',
'platform_refund_id' => $platform_refund_id,
'refund_status_id' => $this->getRefundStatusId($record['status']),
'refund_type_id' => $this->getRefundTypeId($record),
'refund_type_id' => $refund_type_id,
'reason' => $record['reason'] ?? null,
'currency' => 'CNY',
'buyer_user_id' => $record['buyer_open_uid'] ?? null,
@@ -316,15 +331,303 @@ class Refund extends EntityParse
}
// Tmall 判定为 "未发货0秒退"
// Tmall 平台 0秒 退款的业务处理
// 买家下单后立刻反悔,申请退款或者凑单秒退的情况
// 此类订单 Tmall 不推送原始订单数据库中,因此 API 无法抽取到此类数据,需要重建订单信息
// 判定规则为 refund_item.attribute. clj_zero_second_refund
public function fixInstantRefundOrders() : void
/**
* Tmall「0秒闪电退款」订单重建
*
* 买家下单后立刻反悔退款,Tmall 不推送原始订单数据
* 从退款记录的 attribute 字段重建 Order + OrderItem,并回填 RefundItem 日期。
*
* 判定规则: attribute.clj_zero_second_refund === true
*
* @param array $raw_data 原始退款数据数组
*/
public function fixInstantRefundOrders(array $raw_data): void
{
$records = isset($raw_data[0]) ? $raw_data : [$raw_data];
$tz = $this->getStore()->getTimezoneString();
$store_id = $this->getStore()->id;
// Step 1: 解析 attribute,筛选闪电退款记录
$instant_records = [];
foreach ($records as $record) {
$attribute = $record['attribute'] ?? '';
if ($attribute === '') {
continue;
}
$attr = $this->parseRefundItemAttribute($attribute);
if (empty($attr) || !$attr['clj_zero_second_refund']) {
continue;
}
$record['_attr'] = $attr;
$instant_records[] = $record;
}
if (empty($instant_records)) {
return;
}
// Step 2: 按 tidplatform_order_id)分组
$grouped = [];
foreach ($instant_records as $record) {
$tid = strval($record['tid']);
$grouped[$tid][] = $record;
}
// Step 3: 幂等性检查 - 排除 orders 表中已存在的订单
$tids = array_keys($grouped);
$existing_tids = Order::where('store_id', $store_id)
->whereIn('platform_order_id', $tids)
->pluck('platform_order_id')
->toArray();
$grouped = array_diff_key($grouped, array_flip($existing_tids));
if (empty($grouped)) {
dump('[fixInstantRefundOrders] All orders already exist, skipping');
return;
}
dump('[fixInstantRefundOrders] Reconstructing ' . count($grouped) . ' orders from instant refund data');
// Step 4: 按 tid 聚合,重建 Order 记录
$orders_data = [];
$order_dates = []; // tid => created_date
foreach ($grouped as $tid => $tid_records) {
$total_amount_fen = 0;
$total_paid_yuan = 0;
$total_discount_fen = 0;
$max_post_fee_fen = 0;
$refund_ids = [];
$buyer_user_id = null;
$created_date = null;
foreach ($tid_records as $record) {
$attr = $record['_attr'];
// attribute 金额单位: 分
$total_amount_fen += $attr['item_price'] * $attr['item_buy_amount'];
// refund_fee 单位: 元(全额退 = 已付金额)
$total_paid_yuan += (float) ($record['refund_fee'] ?? 0);
foreach ($attr['promotions'] as $promo) {
$total_discount_fen += $promo['amount'];
}
if ($attr['main_order_post_fee'] > $max_post_fee_fen) {
$max_post_fee_fen = $attr['main_order_post_fee'];
}
$refund_ids[] = strval($record['refund_id']);
$buyer_user_id = $record['buyer_open_uid'] ?? $buyer_user_id;
// bgmtc(后台创建时间)优先,否则使用退款创建时间
if ($created_date === null) {
$date_str = $attr['bgmtc'] ?? $record['created'];
$created_date = Carbon::parse($date_str, $tz)->format('Y-m-d H:i:sP');
}
}
$order_dates[$tid] = $created_date;
$raw_json = \json_encode([
'_reconstructed' => true,
'tid' => $tid,
'refund_ids' => $refund_ids,
]);
$ext_json = \json_encode([
'reconstructed_from_refund' => true,
'refund_ids' => $refund_ids,
]);
$orders_data[] = [
'company_id' => $this->getCompany()->id,
'platform_id' => $this->getPlatform()->id,
'store_id' => $store_id,
'order_status_id' => OrderStatus::CANCEL_BEFORE_SHIPPING->value,
'order_type_id' => OrderType::REGRET_ORDER->value,
'platform_order_id' => $tid,
'buyer_user_id' => $buyer_user_id,
'payment_method_id' => PaymentMethod::ALIPAY_CN->value,
'presale' => false,
'total_amount' => $total_amount_fen / 100,
'total_paid' => $total_paid_yuan,
'total_discount' => $total_discount_fen / 100,
'total_received' => 0,
'freight_fee' => $max_post_fee_fen / 100,
'tax_fee' => 0,
'discount_fee' => 0,
'commission_fee' => 0,
'coupon_amount' => 0,
'voucher_amount' => 0,
'created_date' => $created_date,
'updated_date' => null,
'paid_date' => $created_date,
'shipping_date' => null,
'zipcode' => '',
'city' => '',
'province' => '',
'country' => 'CN',
'raw' => $raw_json,
'ext' => $ext_json,
'hash' => \md5($raw_json),
'created_at' => Carbon::now()->format('Y-m-d H:i:sP'),
'updated_at' => Carbon::now()->format('Y-m-d H:i:sP'),
];
}
Order::upsert(
$orders_data,
['store_id', 'platform_order_id', 'created_date'],
[
'order_status_id', 'order_type_id', 'buyer_user_id',
'payment_method_id', 'total_amount', 'total_paid',
'total_discount', 'total_received', 'freight_fee',
'paid_date', 'raw', 'ext', 'hash', 'updated_at',
]
);
// Step 5: 获取 Order ID 映射 [tid => db_order_id]
$tids = array_keys($grouped);
$order_id_map = Order::where('store_id', $store_id)
->whereIn('platform_order_id', $tids)
->pluck('id', 'platform_order_id')
->toArray();
// Step 6: 重建 OrderItem 记录
$items_data = [];
$product_keys = [];
foreach ($grouped as $tid => $tid_records) {
$order_id = $order_id_map[$tid] ?? 0;
$created_date = $order_dates[$tid];
foreach ($tid_records as $record) {
$attr = $record['_attr'];
$index = count($items_data);
$discount_fen = 0;
foreach ($attr['promotions'] as $promo) {
$discount_fen += $promo['amount'];
}
$items_data[] = [
'company_id' => $this->getCompany()->id,
'platform_id' => $this->getPlatform()->id,
'store_id' => $store_id,
'order_id' => $order_id,
'platform_order_id' => $tid,
'sub_order_id' => strval($record['oid']),
'sub_order_type_id' => null,
'product_id' => 0,
'platform_product_id' => isset($record['num_iid']) ? strval($record['num_iid']) : null,
'product_sku' => null,
'product_barcode' => null,
'unit_price' => $attr['item_price'] / 100,
'quantity' => $attr['item_buy_amount'] > 0 ? $attr['item_buy_amount'] : ($record['num'] ?? 1),
'discount' => $discount_fen / 100,
'total' => (float) ($record['refund_fee'] ?? 0),
'ext' => null,
'created_date' => $created_date,
];
// 构建产品查询键: item_id:model_id
$item_id = isset($record['num_iid']) ? strval($record['num_iid']) : null;
$model_id = $attr['sku']['sku_id'] ?? null;
if ($item_id !== null) {
$product_keys[$index] = $item_id . ':' . ($model_id ?? 'null');
}
}
}
// 批量查询产品 ID 并回填
if (!empty($product_keys)) {
$products_map = $this->batchQueryProductsForReconstruction($product_keys);
foreach ($product_keys as $index => $key) {
if (isset($products_map[$key])) {
$items_data[$index]['product_id'] = $products_map[$key];
}
}
}
if (!empty($items_data)) {
OrderItem::upsert(
$items_data,
['store_id', 'platform_order_id', 'sub_order_id', 'created_date'],
[
'order_id', 'product_id', 'platform_product_id',
'unit_price', 'quantity', 'discount', 'total',
]
);
}
// Step 7: 回填 RefundItem 的 order_created_date / order_paid_date
foreach ($grouped as $tid => $tid_records) {
$created_date = $order_dates[$tid];
RefundItem::where('store_id', $store_id)
->where('platform_order_id', $tid)
->whereNull('order_created_date')
->update([
'order_created_date' => $created_date,
'order_paid_date' => $created_date,
]);
}
dump('[fixInstantRefundOrders] Completed: ' . count($orders_data) . ' orders, ' . count($items_data) . ' items reconstructed');
}
/**
* 批量查询产品 ID(用于闪电退款订单重建)
*
* 复用 Tmall\EntityParse\Order 中 batchQueryProducts 的 PostgreSQL VALUES + JOIN 模式
*
* @param array $product_keys 索引 => 查询键 (item_id:model_id) 的映射
* @return array 查询键 => 产品 ID 的映射
*/
private function batchQueryProductsForReconstruction(array $product_keys): array
{
$store_id = $this->getStore()->id;
$unique_keys = array_unique(array_values($product_keys));
if (empty($unique_keys)) {
return [];
}
$values = [];
$bindings = [];
foreach ($unique_keys as $key) {
[$item_id, $model_id] = explode(':', $key, 2);
$values[] = '(?::text, ?::text)';
$bindings[] = $item_id;
$bindings[] = $model_id === 'null' ? null : $model_id;
}
$valuesClause = implode(', ', $values);
$bindings[] = $store_id;
$sql = "
SELECT p.id, p.platform_item_id, p.platform_model_id
FROM (VALUES {$valuesClause}) AS v(item_id, model_id)
INNER JOIN products p
ON p.store_id = ?
AND p.platform_item_id = v.item_id
AND (p.platform_model_id = v.model_id OR (v.model_id IS NULL AND p.platform_model_id IS NULL))
";
$products = Db::select($sql, $bindings);
$map = [];
foreach ($products as $product) {
$key = $product->platform_item_id . ':' . ($product->platform_model_id ?? 'null');
$map[$key] = $product->id;
}
return $map;
}