From 26e5c6806c522cd0e04821370fd7b6f38e5a6657 Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Wed, 4 Mar 2026 14:08:46 +0800 Subject: [PATCH] update tmall refund parse --- .../app/Platform/Tmall/EntityParse/Refund.php | 319 +++++++++++++++- .../FixInstantRefundOrdersTest.php | 339 ++++++++++++++++++ 2 files changed, 650 insertions(+), 8 deletions(-) create mode 100644 backend/test/Cases/Platform/Tmall/EntityParse/FixInstantRefundOrdersTest.php diff --git a/backend/app/Platform/Tmall/EntityParse/Refund.php b/backend/app/Platform/Tmall/EntityParse/Refund.php index 16eace3..ab9dbe5 100644 --- a/backend/app/Platform/Tmall/EntityParse/Refund.php +++ b/backend/app/Platform/Tmall/EntityParse/Refund.php @@ -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: 按 tid(platform_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; } diff --git a/backend/test/Cases/Platform/Tmall/EntityParse/FixInstantRefundOrdersTest.php b/backend/test/Cases/Platform/Tmall/EntityParse/FixInstantRefundOrdersTest.php new file mode 100644 index 0000000..1deb3d2 --- /dev/null +++ b/backend/test/Cases/Platform/Tmall/EntityParse/FixInstantRefundOrdersTest.php @@ -0,0 +1,339 @@ +parser = $reflection->newInstanceWithoutConstructor(); + } + + // ======================================================== + // 闪电退款检测 + // ======================================================== + + /** + * 验证 parseRefundItemAttribute 正确识别闪电退款标识 + */ + public function testDetectsInstantRefundFromAttribute(): void + { + $attrA = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + $attrB = $this->parser->parseRefundItemAttribute($this->sampleAttrB); + + $this->assertTrue($attrA['clj_zero_second_refund']); + $this->assertTrue($attrB['clj_zero_second_refund']); + } + + /** + * 非闪电退款记录不被识别 + */ + public function testNonInstantRefundNotDetected(): void + { + $attr = $this->parser->parseRefundItemAttribute($this->sampleAttrNormal); + + $this->assertFalse($attr['clj_zero_second_refund']); + } + + /** + * 空 attribute 返回空数组 + */ + public function testEmptyAttributeReturnsEmptyArray(): void + { + $attr = $this->parser->parseRefundItemAttribute(''); + + $this->assertSame([], $attr); + } + + // ======================================================== + // 金额解析验证 + // ======================================================== + + /** + * 验证 attribute 中的金额字段(单位: 分)正确解析 + */ + public function testAttributeAmountsInFen(): void + { + $attr = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + + // item_price 39900分 = ¥399.00 + $this->assertSame(39900, $attr['item_price']); + $this->assertSame(1, $attr['item_buy_amount']); + + // 转换为元: 39900 * 1 / 100 = ¥399 + $this->assertEquals(399, $attr['item_price'] * $attr['item_buy_amount'] / 100); + } + + /** + * 验证优惠金额聚合(单位: 分 → 元) + */ + public function testPromotionAmountAggregation(): void + { + $attrA = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + $attrB = $this->parser->parseRefundItemAttribute($this->sampleAttrB); + + // 样本A: 优惠 2211分 = ¥22.11 + $total_discount_a = 0; + foreach ($attrA['promotions'] as $promo) { + $total_discount_a += $promo['amount']; + } + $this->assertSame(2211, $total_discount_a); + $this->assertSame(22.11, $total_discount_a / 100); + + // 样本B: 优惠 6589分 = ¥65.89 + $total_discount_b = 0; + foreach ($attrB['promotions'] as $promo) { + $total_discount_b += $promo['amount']; + } + $this->assertSame(6589, $total_discount_b); + $this->assertSame(65.89, $total_discount_b / 100); + } + + // ======================================================== + // 多子项同 tid 金额聚合 + // ======================================================== + + /** + * 同一主订单下多个子订单的金额正确聚合 + */ + public function testMultiSubOrderAmountAggregation(): void + { + $attrA = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + $attrB = $this->parser->parseRefundItemAttribute($this->sampleAttrB); + + // 模拟两条同 tid 记录的 Order 金额聚合 + $total_amount_fen = 0; + $total_paid_yuan = 0; + $total_discount_fen = 0; + + // 记录A: item_price=39900, refund_fee=177.00 + $total_amount_fen += $attrA['item_price'] * $attrA['item_buy_amount']; + $total_paid_yuan += 177.00; // refund_fee in yuan + foreach ($attrA['promotions'] as $promo) { + $total_discount_fen += $promo['amount']; + } + + // 记录B: item_price=119900, refund_fee=669.00 + $total_amount_fen += $attrB['item_price'] * $attrB['item_buy_amount']; + $total_paid_yuan += 669.00; + foreach ($attrB['promotions'] as $promo) { + $total_discount_fen += $promo['amount']; + } + + // 验证聚合结果 + $this->assertSame(159800, $total_amount_fen); // 39900 + 119900 + $this->assertEquals(1598, $total_amount_fen / 100); // ¥1598.00 + $this->assertSame(846.0, $total_paid_yuan); // ¥177 + ¥669 + $this->assertSame(8800, $total_discount_fen); // 2211 + 6589 + $this->assertEquals(88, $total_discount_fen / 100); // ¥88.00 + } + + // ======================================================== + // Order 字段映射验证 + // ======================================================== + + /** + * 验证 bgmtc 时间字段正确解码(#3B → 冒号) + */ + public function testBgmtcTimeParsing(): void + { + $attr = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + + // bgmtc 原始: 2026-02-24 13#3B47#3B41 → 解码: 2026-02-24 13:47:41 + $this->assertSame('2026-02-24 13:47:41', $attr['bgmtc']); + } + + /** + * 验证运费取最大值逻辑 + */ + public function testFreightFeeMaxSelection(): void + { + $attrA = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + $attrB = $this->parser->parseRefundItemAttribute($this->sampleAttrB); + + // 两个样本 mainOrderPostFee 都是 0 + $max_post_fee = max($attrA['main_order_post_fee'], $attrB['main_order_post_fee']); + $this->assertSame(0, $max_post_fee); + $this->assertEquals(0, $max_post_fee / 100); + } + + /** + * 验证运费正确解析(非零场景) + */ + public function testFreightFeeNonZero(): void + { + $attr = $this->parser->parseRefundItemAttribute($this->sampleAttrNormal); + + // mainOrderPostFee:1000 = ¥10.00 + $this->assertSame(1000, $attr['main_order_post_fee']); + $this->assertEquals(10, $attr['main_order_post_fee'] / 100); + } + + // ======================================================== + // OrderItem 字段映射验证 + // ======================================================== + + /** + * 验证 SKU 正确提取用于产品查询键 + */ + public function testProductKeyFromAttribute(): void + { + $attrA = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + $attrB = $this->parser->parseRefundItemAttribute($this->sampleAttrB); + + // 产品查询键: num_iid:sku_id + $this->assertSame('5085821404607', $attrA['sku']['sku_id']); + $this->assertSame('5112591851366', $attrB['sku']['sku_id']); + } + + /** + * 验证 quantity 使用 item_buy_amount + */ + public function testQuantityFromAttribute(): void + { + $attr = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + + $this->assertSame(1, $attr['item_buy_amount']); + + // item_buy_amount > 0 时使用 attribute 值 + $quantity = $attr['item_buy_amount'] > 0 ? $attr['item_buy_amount'] : 1; + $this->assertSame(1, $quantity); + } + + /** + * item_buy_amount = 0 时回退到 record['num'] + */ + public function testQuantityFallbackToNum(): void + { + // 当 attribute 中 itemBuyAmount 为 0 或缺失 + $attr = $this->parser->parseRefundItemAttribute(';bizCode:test;itemBuyAmount:0;'); + + $record_num = 3; + $quantity = $attr['item_buy_amount'] > 0 ? $attr['item_buy_amount'] : $record_num; + $this->assertSame(3, $quantity); + } + + // ======================================================== + // RefundType 判定验证 + // ======================================================== + + /** + * 闪电退款应返回 INSTANT_REFUND_WITHOUT_RETURN + */ + public function testInstantRefundTypeOverride(): void + { + $attr = $this->parser->parseRefundItemAttribute($this->sampleAttrA); + + $this->assertTrue($attr['clj_zero_second_refund']); + // 当 clj_zero_second_refund=true 时,refund_type_id 应为 5 + $this->assertSame(5, RefundType::INSTANT_REFUND_WITHOUT_RETURN->value); + } + + /** + * 非闪电退款维持原有判定逻辑 + */ + public function testNormalRefundTypePreserved(): void + { + // has_good_return=false, good_status=BUYER_NOT_RECEIVED → RETURN_BEFORE_SHIPPING (1) + $record = ['has_good_return' => false, 'good_status' => 'BUYER_NOT_RECEIVED']; + $type_id = $this->parser->getRefundTypeId($record); + $this->assertSame(RefundType::RETURN_BEFORE_SHIPPING->value, $type_id); + + // has_good_return=true → RETURN_AND_REFUND (2) + $record = ['has_good_return' => true, 'good_status' => '']; + $type_id = $this->parser->getRefundTypeId($record); + $this->assertSame(RefundType::RETURN_AND_REFUND->value, $type_id); + } + + // ======================================================== + // Order 常量值验证 + // ======================================================== + + /** + * 确认重建 Order 使用的常量值 + */ + public function testReconstructedOrderConstants(): void + { + $this->assertSame(9, OrderStatus::CANCEL_BEFORE_SHIPPING->value); + $this->assertSame(9, OrderType::REGRET_ORDER->value); + $this->assertSame(2, PaymentMethod::ALIPAY_CN->value); + } + + // ======================================================== + // 边缘情况 + // ======================================================== + + /** + * attribute 无 SKU 信息时 sku 为 null + */ + public function testMissingSkuInAttribute(): void + { + $attr = $this->parser->parseRefundItemAttribute(';clj_zero_second_refund:1;itemPrice:10000;itemBuyAmount:1;'); + + $this->assertTrue($attr['clj_zero_second_refund']); + $this->assertNull($attr['sku']); + + // 无 SKU 时产品查询键的 model_id 应为 null + $model_id = $attr['sku']['sku_id'] ?? null; + $this->assertNull($model_id); + + // 查询键格式: item_id:null + $key = '12345:' . ($model_id ?? 'null'); + $this->assertSame('12345:null', $key); + } + + /** + * 无优惠信息时 discount = 0 + */ + public function testNoPromotionsDiscount(): void + { + $attr = $this->parser->parseRefundItemAttribute(';clj_zero_second_refund:1;itemPrice:10000;pmtR:;'); + + $total_discount = 0; + foreach ($attr['promotions'] as $promo) { + $total_discount += $promo['amount']; + } + + $this->assertSame(0, $total_discount); + } +}