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

595 lines
22 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\RefundStatus;
use App\Constants\RefundType;
use App\Platform\Tmall\Constants\RefundStatus as TmallRefundStatus;
use App\Model\Company;
use App\Model\Order;
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 表
*
* 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);
$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' => $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),
'order_created_date' => null, // @attention 暂时设置为 null 后续会批量更新
'order_paid_date' => null, // @attention 暂时设置为 null 后续会批量更新
'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 平台 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);
}
}