Files
datahub/backend/app/Platform/Tmall/EntityParse/Refund.php
T

595 lines
22 KiB
PHP
Raw Normal View History

2026-02-10 13:53:53 +08:00
<?php
declare(strict_types=1);
namespace App\Platform\Tmall\EntityParse;
use App\Constants\RefundStatus;
use App\Constants\RefundType;
use App\Platform\Tmall\Constants\RefundStatus as TmallRefundStatus;
use App\Model\Company;
2026-02-11 10:51:41 +08:00
use App\Model\Order;
2026-02-10 13:53:53 +08:00
use App\Model\Store;
use App\Entity\Parse\EntityParse;
use Carbon\Carbon;
use Hyperf\Collection\LazyCollection;
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 表
*
2026-02-11 10:10:21 +08:00
* Tmall 不存在父退款订单,因此此方法返回空值
2026-02-10 13:53:53 +08:00
*
* @param array $raw_data 原始数据数组
* @return LazyCollection 每个元素为可供 Refund Model::fill() 使用的数组
*/
public function entityMap(array $raw_data): LazyCollection
{
2026-02-11 10:10:21 +08:00
return LazyCollection::make();
2026-02-10 13:53:53 +08:00
}
/**
* 获取实体的唯一键字段(用于 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',
];
}
2026-02-11 10:10:21 +08:00
/**
* Tmall 没有父级退款单
* @return bool
*/
public function hasParentRefund(): bool
{
return false;
}
2026-02-10 13:53:53 +08:00
/**
* 格式化退款子项数据 - 映射到 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
*/
2026-02-11 10:10:21 +08:00
public function formatRefundItemsFromRaw(array $raw_data, array| null $platform_refund_id_to_local_refund_id_map = null): array
2026-02-10 13:53:53 +08:00
{
$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);
$items[] = [
'company_id' => $this->getCompany()->id,
'platform_id' => $this->getPlatform()->id,
'store_id' => $this->getStore()->id,
2026-02-11 10:10:21 +08:00
'refund_id' => 0,
2026-02-10 13:53:53 +08:00
'platform_parent_refund_id' => '',
'platform_refund_id' => $platform_refund_id,
'refund_status_id' => $this->getRefundStatusId($record['status']),
'refund_type_id' => $this->getRefundTypeId($record),
'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),
2026-02-11 10:10:21 +08:00
'order_created_date' => null, // @attention 暂时设置为 null 后续会批量更新
'order_paid_date' => null, // @attention 暂时设置为 null 后续会批量更新
2026-02-10 13:53:53 +08:00
'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'),
];
}
2026-02-11 10:51:41 +08:00
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);
2026-02-10 13:53:53 +08:00
return $items;
}
2026-02-11 10:51:41 +08:00
/**
* 根据 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;
}
2026-02-10 13:53:53 +08:00
/**
* 将 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;
}
2026-02-25 14:34:33 +08:00
// 买家未收到货 → 未发货前退款
2026-02-10 13:53:53 +08:00
if ($good_status === 'BUYER_NOT_RECEIVED') {
2026-02-25 14:34:33 +08:00
return RefundType::RETURN_BEFORE_SHIPPING->value;
2026-02-10 13:53:53 +08:00
}
// 买家已收货但无须退货 → 退款无须退货
if ($good_status === 'BUYER_RECEIVED') {
return RefundType::REFUND_WITHOUT_RETURN->value;
}
2026-02-25 14:34:33 +08:00
return RefundType::RETURN_BEFORE_SHIPPING->value;
}
// Tmall 判定为 "未发货0秒退"
// Tmall 平台 0秒 退款的业务处理
// 买家下单后立刻反悔,申请退款或者凑单秒退的情况
// 此类订单 Tmall 不会推送到原始订单数据库中,因此 API 无法抽取到此类数据,需要重建订单信息
// 判定规则为 refund_item.attribute. clj_zero_second_refund
public function fixInstantRefundOrders() : void
{
}
/**
* 解析退款子项的 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);
2026-02-10 13:53:53 +08:00
}
}