update tmall refund parse
This commit is contained in:
@@ -4,15 +4,21 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Platform\Tmall\EntityParse;
|
namespace App\Platform\Tmall\EntityParse;
|
||||||
|
|
||||||
|
use App\Constants\OrderStatus;
|
||||||
|
use App\Constants\OrderType;
|
||||||
|
use App\Constants\PaymentMethod;
|
||||||
use App\Constants\RefundStatus;
|
use App\Constants\RefundStatus;
|
||||||
use App\Constants\RefundType;
|
use App\Constants\RefundType;
|
||||||
use App\Platform\Tmall\Constants\RefundStatus as TmallRefundStatus;
|
use App\Platform\Tmall\Constants\RefundStatus as TmallRefundStatus;
|
||||||
use App\Model\Company;
|
use App\Model\Company;
|
||||||
use App\Model\Order;
|
use App\Model\Order;
|
||||||
|
use App\Model\OrderItem;
|
||||||
|
use App\Model\RefundItem;
|
||||||
use App\Model\Store;
|
use App\Model\Store;
|
||||||
use App\Entity\Parse\EntityParse;
|
use App\Entity\Parse\EntityParse;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Hyperf\Collection\LazyCollection;
|
use Hyperf\Collection\LazyCollection;
|
||||||
|
use Hyperf\DbConnection\Db;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,6 +150,15 @@ class Refund extends EntityParse
|
|||||||
$tz = $this->getStore()->getTimezoneString();
|
$tz = $this->getStore()->getTimezoneString();
|
||||||
$raw = \json_encode($record);
|
$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[] = [
|
$items[] = [
|
||||||
'company_id' => $this->getCompany()->id,
|
'company_id' => $this->getCompany()->id,
|
||||||
'platform_id' => $this->getPlatform()->id,
|
'platform_id' => $this->getPlatform()->id,
|
||||||
@@ -152,7 +167,7 @@ class Refund extends EntityParse
|
|||||||
'platform_parent_refund_id' => '',
|
'platform_parent_refund_id' => '',
|
||||||
'platform_refund_id' => $platform_refund_id,
|
'platform_refund_id' => $platform_refund_id,
|
||||||
'refund_status_id' => $this->getRefundStatusId($record['status']),
|
'refund_status_id' => $this->getRefundStatusId($record['status']),
|
||||||
'refund_type_id' => $this->getRefundTypeId($record),
|
'refund_type_id' => $refund_type_id,
|
||||||
'reason' => $record['reason'] ?? null,
|
'reason' => $record['reason'] ?? null,
|
||||||
'currency' => 'CNY',
|
'currency' => 'CNY',
|
||||||
'buyer_user_id' => $record['buyer_open_uid'] ?? null,
|
'buyer_user_id' => $record['buyer_open_uid'] ?? null,
|
||||||
@@ -316,15 +331,303 @@ class Refund extends EntityParse
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Tmall 判定为 "未发货0秒退"
|
/**
|
||||||
// Tmall 平台 0秒 退款的业务处理
|
* Tmall「0秒闪电退款」订单重建
|
||||||
// 买家下单后立刻反悔,申请退款或者凑单秒退的情况
|
*
|
||||||
// 此类订单 Tmall 不会推送到原始订单数据库中,因此 API 无法抽取到此类数据,需要重建订单信息
|
* 买家下单后立刻反悔退款,Tmall 不推送原始订单数据。
|
||||||
// 判定规则为 refund_item.attribute. clj_zero_second_refund
|
* 从退款记录的 attribute 字段重建 Order + OrderItem,并回填 RefundItem 日期。
|
||||||
public function fixInstantRefundOrders() : void
|
*
|
||||||
|
* 判定规则: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace HyperfTest\Cases\Platform\Tmall\EntityParse;
|
||||||
|
|
||||||
|
use App\Constants\OrderStatus;
|
||||||
|
use App\Constants\OrderType;
|
||||||
|
use App\Constants\PaymentMethod;
|
||||||
|
use App\Constants\RefundType;
|
||||||
|
use App\Platform\Tmall\EntityParse\Refund;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use ReflectionMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tmall 闪电退款订单重建 - 数据映射逻辑测试
|
||||||
|
*
|
||||||
|
* 测试 fixInstantRefundOrders 中的纯数据转换逻辑,
|
||||||
|
* 不涉及数据库操作(DB 交互由集成测试覆盖)。
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class FixInstantRefundOrdersTest extends TestCase
|
||||||
|
{
|
||||||
|
private Refund $parser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 样本A attribute: 商品单价 ¥399,退款 ¥177,优惠 ¥22.11
|
||||||
|
*/
|
||||||
|
private string $sampleAttrA = ';fundFlowInfo:CASH^ALIPAY_FUND^支付宝^15489|OTHER_ASSETS^OTHER_ASSETS_GROUP^优惠^2211;bizCode:tmall.hk.refund;disputeRequest:1;leavesCat:50026924;apply_reason_text:不喜欢/不想要;itemBuyAmount:1;seller_batch:true;clj_zero_second_refund:1;DBT:InterceptUnconsignRefund;sku:5085821404607|口味#3B橘子味#3A颜色分类#3B维C 250mg;bgmtc:2026-02-24 13#3B47#3B41;shop_name:TestShop;pmtR:TAPP_USERCOUPON_SP^2211|;tmgSimpleZeroRefund:1;isVirtual:0;EXmrf:17700;warehouseRefund:1;apply_init_refund_fee:17700;mainOrderPostFee:0;itemPrice:39900;toSellerFee:0;';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 样本B attribute: 商品单价 ¥1199,退款 ¥669,优惠 ¥65.89
|
||||||
|
*/
|
||||||
|
private string $sampleAttrB = ';bizCode:tmall.hk.refund;disputeRequest:1;leavesCat:50026872;apply_reason_text:不喜欢/不想要;itemBuyAmount:1;seller_batch:true;clj_zero_second_refund:1;DBT:InterceptUnconsignRefund;sku:5112591851366|颜色分类#3B120粒;bgmtc:2026-02-24 00#3B03#3B54;shop_name:TestShop;pmtR:TAPP_USERCOUPON_SP^6589|;tmgSimpleZeroRefund:1;isVirtual:0;EXmrf:66900;warehouseRefund:1;apply_init_refund_fee:66900;mainOrderPostFee:0;itemPrice:119900;toSellerFee:0;';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非闪电退款 attribute (无 clj_zero_second_refund)
|
||||||
|
*/
|
||||||
|
private string $sampleAttrNormal = ';bizCode:tmall.hk.refund;itemBuyAmount:2;itemPrice:19900;mainOrderPostFee:1000;sku:123456|颜色#3B红色;pmtR:;';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass(Refund::class);
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user