Files
datahub/backend/app/Platform/Tmall/EntityParse/Refund.php
T
2026-03-04 14:29:25 +08:00

905 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
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;
/**
* 天猫退款解析器
*
* Tmall 没有主退款和子退款的概念,每个售后单对应一个订单子项。
* 每个售后单同时写入 refunds(主记录)和 refund_items(明细)两张表。
*/
class Refund extends EntityParse
{
/**
* 公司作用域匹配
*
* @param array $metadata
* @return Company
* @throws InvalidArgumentException
*/
public function companyScopeMatch(array $metadata): Company
{
if (!isset($metadata['company_id'])) {
throw new InvalidArgumentException('company_id is required in metadata');
}
$company = Company::find($metadata['company_id']);
if (!$company) {
throw new InvalidArgumentException("Company with ID {$metadata['company_id']} not found");
}
return $company;
}
/**
* 店铺作用域匹配
*
* @param array $metadata
* @return Store
* @throws InvalidArgumentException
*/
public function storeScopeMatch(array $metadata): Store
{
if (!isset($metadata['store_id'])) {
throw new InvalidArgumentException('store_id is required in metadata');
}
$store = Store::find($metadata['store_id']);
if (!$store) {
throw new InvalidArgumentException("Store with ID {$metadata['store_id']} not found");
}
return $store;
}
/**
* 实体数据映射 - 映射到 refunds 表
*
* Tmall 不存在父退款订单,因此此方法返回空值
*
* @param array $raw_data 原始数据数组
* @return LazyCollection 每个元素为可供 Refund Model::fill() 使用的数组
*/
public function entityMap(array $raw_data): LazyCollection
{
return LazyCollection::make();
}
/**
* 获取实体的唯一键字段(用于 upsert 的 uniqueBy 参数)
*
* @return array
*/
public function getUniqueBy(): array
{
return ['store_id', 'platform_refund_id'];
}
/**
* 获取可更新的字段列表(用于 upsert 的 update 参数)
*
* @return array
*/
public function getUpdateFields(): array
{
return [
'order_id',
'refund_status_id',
'refund_type_id',
'reason',
'refund_amount',
'freight_refund',
'refund_total',
'buyer_user_id',
'updated_date',
'completed_date',
'raw',
'ext',
'hash',
'updated_at',
];
}
/**
* Tmall 没有父级退款单
* @return bool
*/
public function hasParentRefund(): bool
{
return false;
}
/**
* 格式化退款子项数据 - 映射到 refund_items 表
*
* Tmall 每个售后单同时作为一条 refund_item 记录
* 需要在 refunds upsert 完成后调用,以获取 refund_id 映射
*
* @param array $raw_data 原始数据数组
* @param array $platform_refund_id_to_local_refund_id_map [platform_refund_id => local db refund id]
* @return array
*/
public function formatRefundItemsFromRaw(array $raw_data, array| null $platform_refund_id_to_local_refund_id_map = null): array
{
$records = isset($raw_data[0]) ? $raw_data : [$raw_data];
$items = [];
foreach ($records as $record) {
$platform_refund_id = strval($record['refund_id']);
$tz = $this->getStore()->getTimezoneString();
$raw = \json_encode($record);
// 闪电退款: 覆盖退款类型 + 预填订单日期(闪电退款无原始订单,fillOrderDates 查不到)
$refund_type_id = $this->getRefundTypeId($record);
$instant_refund_order_date = null;
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;
// bgmtc 是「退款后台创建时间」,并非真实订单创建时间。
// 闪电退款场景下 Tmall 不推送原始订单,bgmtc 是我们能获取到的最接近订单创建时间的时间戳(误差在秒~分钟级别),
// 用作 order_created_date / order_paid_date 的近似值。
$date_str = $attr['bgmtc'] ?? $record['created'];
$instant_refund_order_date = Carbon::parse($date_str, $tz)->format('Y-m-d H:i:sP');
}
}
$items[] = [
'company_id' => $this->getCompany()->id,
'platform_id' => $this->getPlatform()->id,
'store_id' => $this->getStore()->id,
'refund_id' => 0,
'platform_parent_refund_id' => '',
'platform_refund_id' => $platform_refund_id,
'refund_status_id' => $this->getRefundStatusId($record['status']),
'refund_type_id' => $refund_type_id,
'reason' => $record['reason'] ?? null,
'currency' => 'CNY',
'buyer_user_id' => $record['buyer_open_uid'] ?? null,
'platform_order_id' => strval($record['tid']),
'platform_sub_order_id' => strval($record['oid']),
'platform_product_id' => isset($record['num_iid']) ? strval($record['num_iid']) : null,
'quantity' => $record['num'] ?? 0,
'refund_amount' => (float) ($record['refund_fee'] ?? 0),
'order_created_date' => $instant_refund_order_date, // 闪电退款用 bgmtc 预填,普通退款由 fillOrderDates 回填
'order_paid_date' => $instant_refund_order_date,
'created_date' => Carbon::parse($record['created'], $tz)->format('Y-m-d H:i:sP'),
'updated_date' => isset($record['modified']) ? Carbon::parse($record['modified'], $tz)->format('Y-m-d H:i:sP') : null,
'completed_date' => isset($record['end_time']) ? Carbon::parse($record['end_time'], $tz)->format('Y-m-d H:i:sP') : null,
'raw' => $raw,
'ext' => null,
'created_at' => Carbon::now()->format('Y-m-d H:i:sP'),
'updated_at' => Carbon::now()->format('Y-m-d H:i:sP'),
];
}
return $this->fillOrderDates($items);
}
/**
* 根据退款子项的 store_id + platform_order_id 查询订单,
* 将 order_created_date 和 order_paid_date 填入 $items 数组
*
* 订单搜索范围:[退款 created_date - 3个月, 退款 created_date]
*
* @param array $items 退款子项数据数组
* @return array 填充了订单日期的退款子项数组
*/
protected function fillOrderDates(array $items): array
{
$lookups = [];
foreach ($items as $item) {
$key = $item['store_id'] . ':' . $item['platform_order_id'];
if (!isset($lookups[$key])) {
$lookups[$key] = [
'store_id' => $item['store_id'],
'platform_order_id' => $item['platform_order_id'],
'created_date' => $item['created_date'],
];
}
}
if (empty($lookups)) {
return $items;
}
$orderDatesMap = $this->queryOrderDates($lookups);
dump("Matched " . count($orderDatesMap) . " orders for " . count($lookups) . " refund item lookups");
foreach ($items as &$item) {
$key = $item['store_id'] . ':' . $item['platform_order_id'];
if (isset($orderDatesMap[$key])) {
$item['order_created_date'] = $orderDatesMap[$key]['order_created_date'];
$item['order_paid_date'] = $orderDatesMap[$key]['order_paid_date'];
}
}
unset($item);
return $items;
}
/**
* 根据 store_id + platform_order_id 批量查询订单的创建时间和付款时间
*
* 订单搜索范围:[退款 created_date - 3个月, 退款 created_date]
*
* @param array $lookups [key => ['store_id', 'platform_order_id', 'created_date']]
* @return array [store_id:platform_order_id => ['order_created_date' => ..., 'order_paid_date' => ...]]
*/
protected function queryOrderDates(array $lookups): array
{
$orders = Order::query()
->where(function ($query) use ($lookups) {
foreach ($lookups as $lookup) {
$createdDate = Carbon::parse($lookup['created_date']);
$query->orWhere(function ($q) use ($lookup, $createdDate) {
$q->where('store_id', $lookup['store_id'])
->where('platform_order_id', $lookup['platform_order_id'])
->whereBetween('created_date', [
$createdDate->copy()->subMonths(3),
$createdDate,
]);
});
}
})
->get(['store_id', 'platform_order_id', 'created_date', 'paid_date']);
$map = [];
foreach ($orders as $order) {
$key = $order->store_id . ':' . $order->platform_order_id;
$map[$key] = [
'order_created_date' => $order->created_date,
'order_paid_date' => $order->paid_date,
];
}
return $map;
}
/**
* 将 Tmall 退款状态映射为系统退款状态 ID
*
* @param string $platformStatus
* @return int
*/
public function getRefundStatusId(string $platform_status): int
{
$map = $this->refundStatusMap();
return $map[$platform_status] ?? RefundStatus::APPLIED->value;
}
/**
* Tmall 退款状态 → 系统退款状态映射
*
* @return array<string, int>
*/
private function refundStatusMap(): array
{
return [
TmallRefundStatus::WAIT_SELLER_AGREE->value => RefundStatus::APPLIED->value,
TmallRefundStatus::WAIT_BUYER_RETURN_GOODS->value => RefundStatus::SELLER_ACCEPTED->value,
TmallRefundStatus::WAIT_SELLER_CONFIRM_GOODS->value => RefundStatus::SELLER_ACCEPTED->value,
TmallRefundStatus::SELLER_REFUSE_BUYER->value => RefundStatus::REFUSED->value,
TmallRefundStatus::SUCCESS->value => RefundStatus::SUCCESS->value,
TmallRefundStatus::CLOSED->value => RefundStatus::CLOSED->value,
];
}
/**
* 根据 Tmall 售后单数据判断退款类型
*
* @param array $record 单条售后单原始数据
* @return int
*/
public function getRefundTypeId(array $record): int
{
$has_good_return = $record['has_good_return'] ?? false;
$good_status = $record['good_status'] ?? '';
// 买家已退货或等待退货 → 退货退款
if ($has_good_return) {
return RefundType::RETURN_AND_REFUND->value;
}
// 买家未收到货 → 未发货前退款
if ($good_status === 'BUYER_NOT_RECEIVED') {
return RefundType::RETURN_BEFORE_SHIPPING->value;
}
// 买家已收货但无须退货 → 退款无须退货
if ($good_status === 'BUYER_RECEIVED') {
return RefundType::REFUND_WITHOUT_RETURN->value;
}
return RefundType::RETURN_BEFORE_SHIPPING->value;
}
/**
* 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;
}
/**
* 解析退款子项的 attribute 字段为结构化数据
*
* Tmall attribute 原始格式: ;key:value;key:value;...
* 编码规则: #3B → :(冒号) #3A → ;(分号)
*
* @param string $attribute 原始 attribute 字符串
* @return array 结构化属性数据
*/
public function parseRefundItemAttribute(string $attribute): array
{
$pairs = $this->parseAttributePairs($attribute);
if (empty($pairs)) {
return [];
}
return [
// ── 基本信息 ──
'biz_code' => $pairs['bizCode'] ?? null, // 业务编码 (tmall.hk.refund=天猫国际退款)
'sdk_code' => $pairs['sdkCode'] ?? null, // SDK来源
'shop_name' => $pairs['shop_name'] ?? null, // 店铺名称
'workflow_name' => $pairs['workflowName'] ?? null, // 工作流
'op_role' => $pairs['opRole'] ?? null, // 操作角色 (daemon=系统自动)
// ── 商品信息 ──
'sku' => $this->parseSkuAttribute($pairs['sku'] ?? ''), // SKU ID + 规格属性
'item_buy_amount' => (int) ($pairs['itemBuyAmount'] ?? 0), // 购买数量
'item_price' => (int) ($pairs['itemPrice'] ?? 0), // 商品单价 (分)
'leaves_cat' => $pairs['leavesCat'] ?? null, // 叶子类目ID
'root_cat' => $pairs['rootCat'] ?? null, // 根类目ID
'is_virtual' => (bool) ($pairs['isVirtual'] ?? 0), // 是否虚拟商品
// ── 退款金额 (单位: 分) ──
'apply_init_refund_fee' => (int) ($pairs['apply_init_refund_fee'] ?? 0), // 申请退款金额
'ex_max_refund_fee' => (int) ($pairs['EXmrf'] ?? 0), // 最大可退金额
'online_refund_fee' => (int) ($pairs['ol_tf'] ?? 0), // 在线退款金额
'refund_post_fee' => (int) ($pairs['refundPostFee'] ?? 0), // 退款运费
'main_order_post_fee' => (int) ($pairs['mainOrderPostFee'] ?? 0), // 主单运费
'to_seller_fee' => (int) ($pairs['toSellerFee'] ?? 0), // 卖家应付金额
// ── 退款原因与类型 ──
'apply_reason_text' => $pairs['apply_reason_text'] ?? null, // 退款原因文本
'apply_text_id' => $pairs['apply_text_id'] ?? null, // 退款原因ID
'dispute_request' => (int) ($pairs['disputeRequest'] ?? 0), // 是否有纠纷
'dispute_trade_status' => (int) ($pairs['disputeTradeStatus'] ?? 0), // 纠纷交易状态
'dbt' => $pairs['DBT'] ?? null, // 退款业务类型 (InterceptUnconsignRefund=拦截未发货退款)
// ── 退款特征标识 ──
'clj_zero_second_refund' => (bool) ($pairs['clj_zero_second_refund'] ?? 0), // 0秒闪电退款 (为 true 时需重建订单, 见 fixInstantRefundOrders)
'tmg_simple_zero_refund' => (bool) ($pairs['tmgSimpleZeroRefund'] ?? 0), // 简易0秒退款
'warehouse_refund' => (bool) ($pairs['warehouseRefund'] ?? 0), // 仓库退款
'part_refund' => (bool) ($pairs['part_refund'] ?? 0), // 部分退款
'percent_refund' => (bool) ($pairs['percent_refund'] ?? 0), // 比例退款
'products' => $pairs['products'] ?? null, // 退款产品标识 (timeoutrefund=超时退款)
// ── 资金流 ──
'fund_flow' => $this->parseFundFlowAttribute($pairs['fundFlowInfo'] ?? ''), // 资金渠道明细 [{type, group, name, amount}]
'pay_mode' => $pairs['payMode'] ?? null, // 支付方式
'pay_lock' => $pairs['pay_lock'] ?? null, // 支付锁定方
// ── 优惠信息 ──
'promotions' => $this->parsePromotionsAttribute($pairs['pmtR'] ?? ''), // 优惠明细 [{type, amount}]
'has_threshold_coupon' => (bool) ($pairs['has_threshold_coupon'] ?? 0), // 是否有门槛券
'threshold_instruction' => $this->decodeAttributeJson($pairs['threshold_instruction'] ?? null), // 门槛券指令
// ── 价保 ──
'price_protection' => $this->parsePriceProtectionAttribute($pairs['price_protection'] ?? ''), // 价保时间范围 {start, end}
// ── 物流拦截 ──
'intercept_type' => $pairs['interceptType'] ?? null, // 拦截类型 (clj=菜鸟)
'intercept_status' => (int) ($pairs['interceptStatus'] ?? 0), // 拦截状态
'intercept_items' => $this->decodeAttributeJson($pairs['interceptItemListResult'] ?? null), // 拦截结果明细
'ability_template_code' => $pairs['abilityTemplateCode'] ?? null, // 能力模板编码
'ability_success_flag' => (bool) ($pairs['abilitySuccessFlag'] ?? 0), // 能力执行是否成功
'warehouse_intercept_op' => (int) ($pairs['sellerOpAbWarehouseIntercept'] ?? 0), // 卖家仓库拦截操作
// ── 卖家/买家 ──
'seller_batch' => (bool) ($pairs['seller_batch'] ?? 0), // 是否批量处理
'seller_audit' => (int) ($pairs['seller_audit'] ?? 0), // 卖家审核状态
'has_seller_memo' => (bool) ($pairs['hasSellerMemo'] ?? 0), // 是否有卖家备注
'user_credit' => (int) ($pairs['userCredit'] ?? 0), // 买家信用等级
'agree_source' => $pairs['agreeSource'] ?? null, // 同意来源 (clj=菜鸟自动同意)
// ── 其他 ──
'channel_sub_order_id' => $pairs['chnlObn'] ?? null, // 渠道子订单编号
'last_order' => (bool) ($pairs['lastOrder'] ?? 0), // 是否最后一笔子单
'b2c' => (bool) ($pairs['b2c'] ?? 0), // 是否B2C
'bgmtc' => $pairs['bgmtc'] ?? null, // 退款后台创建时间(非订单创建时间,闪电退款场景用作订单时间近似值)
];
}
/**
* 将 attribute 原始字符串拆分为 key => value 键值对
*
* 格式: ;key:value;key:value;...
* 解码: #3B → :(冒号) #3A → ;(分号)
*
* @param string $attribute
* @return array<string, string>
*/
private function parseAttributePairs(string $attribute): array
{
$pairs = [];
$segments = explode(';', $attribute);
foreach ($segments as $segment) {
$segment = trim($segment);
if ($segment === '') {
continue;
}
$colonPos = strpos($segment, ':');
if ($colonPos === false) {
continue;
}
$key = substr($segment, 0, $colonPos);
$value = substr($segment, $colonPos + 1);
// #3B → :(冒号) #3A → ;(分号)
$value = str_replace(['#3B', '#3A'], [':', ';'], $value);
$pairs[$key] = $value;
}
return $pairs;
}
/**
* 解析 SKU 属性
*
* 格式: SKU_ID|属性名:属性值;属性名:属性值
* 示例: 5085821404607|口味:橘子味;颜色分类:维C 250mg
*
* @param string $value 已解码的 SKU 字符串
* @return array|null
*/
private function parseSkuAttribute(string $value): ?array
{
if ($value === '') {
return null;
}
$parts = explode('|', $value, 2);
$result = [
'sku_id' => $parts[0],
'properties' => [],
];
if (isset($parts[1])) {
foreach (explode(';', $parts[1]) as $prop) {
$kv = explode(':', $prop, 2);
if (count($kv) === 2) {
$result['properties'][$kv[0]] = $kv[1];
}
}
}
return $result;
}
/**
* 解析资金流信息
*
* 格式: TYPE^GROUP^NAME^AMOUNT|TYPE^GROUP^NAME^AMOUNT
* 示例: CASH^ALIPAY_FUND^支付宝^15489|OTHER_ASSETS^OTHER_ASSETS_GROUP^优惠^2211
*
* @param string $value
* @return array [{type, group, name, amount}]
*/
private function parseFundFlowAttribute(string $value): array
{
if ($value === '') {
return [];
}
$flows = [];
foreach (array_filter(explode('|', $value)) as $item) {
$parts = explode('^', $item);
if (count($parts) >= 4) {
$flows[] = [
'type' => $parts[0], // 资金类型 (CASH=现金 / OTHER_ASSETS=其他资产)
'group' => $parts[1], // 资金组 (ALIPAY_FUND=支付宝 / OTHER_ASSETS_GROUP=优惠资产)
'name' => $parts[2], // 显示名称
'amount' => (int) $parts[3], // 金额 (分)
];
}
}
return $flows;
}
/**
* 解析优惠明细
*
* 格式: TYPE^AMOUNT|TYPE^AMOUNT|
* 示例: TAPP_USERCOUPON_SP^2211|
*
* @param string $value
* @return array [{type, amount}]
*/
private function parsePromotionsAttribute(string $value): array
{
if ($value === '') {
return [];
}
$promotions = [];
foreach (array_filter(explode('|', $value)) as $item) {
$parts = explode('^', $item);
if (count($parts) >= 2) {
$promotions[] = [
'type' => $parts[0], // 优惠类型 (TAPP_USERCOUPON_SP=平台优惠券)
'amount' => (int) $parts[1], // 优惠金额 (分)
];
}
}
return $promotions;
}
/**
* 解析价保时间范围
*
* 格式: START_TIME~END_TIME
* 示例: 2026-02-24 13:47:45~2026-03-16 23:59:59
*
* @param string $value
* @return array|null {start, end}
*/
private function parsePriceProtectionAttribute(string $value): ?array
{
if ($value === '') {
return null;
}
$parts = explode('~', $value, 2);
return [
'start' => $parts[0] ?? null,
'end' => $parts[1] ?? null,
];
}
/**
* 解码 attribute 中内嵌的 JSON 字符串
*
* @param string|null $value
* @return mixed
*/
private function decodeAttributeJson(?string $value): mixed
{
if ($value === null || $value === '') {
return null;
}
// attribute 中的 JSON 使用 \" 转义双引号,需先还原
return json_decode(stripslashes($value), true);
}
}