461 lines
17 KiB
PHP
461 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Platform\Tmall\EntityParse;
|
||
|
||
use App\Constants\OrderStatus;
|
||
use App\Model\Company;
|
||
use App\Model\Model as Entity;
|
||
use App\Model\Product;
|
||
use App\Model\Store;
|
||
use App\Entity\Parse\EntityParse;
|
||
use App\Constants\PaymentMethod;
|
||
use App\Constants\OrderType;
|
||
use Carbon\Carbon;
|
||
use Hyperf\Collection\LazyCollection;
|
||
use InvalidArgumentException;
|
||
|
||
/**
|
||
* 天猫订单解析器示例
|
||
*
|
||
* 展示如何继承 EntityParse 实现自定义解析逻辑
|
||
*/
|
||
class Order extends EntityParse
|
||
{
|
||
/**
|
||
* 公司作用域匹配
|
||
*
|
||
* 从 metadata 中提取 company_id,然后查询数据库获取公司对象
|
||
*
|
||
* @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_id = $metadata['company_id'];
|
||
|
||
$company = Company::find($company_id);
|
||
|
||
if (!$company) {
|
||
throw new InvalidArgumentException("Company with ID {$company_id} not found");
|
||
}
|
||
|
||
return $company;
|
||
}
|
||
|
||
/**
|
||
* 店铺作用域匹配
|
||
*
|
||
* 从 metadata 中提取 store_id,然后查询数据库获取店铺对象
|
||
*
|
||
* @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_id = $metadata['store_id'];
|
||
|
||
$store = Store::find($store_id);
|
||
|
||
if (!$store) {
|
||
throw new InvalidArgumentException("Store with ID {$store_id} not found");
|
||
}
|
||
|
||
return $store;
|
||
}
|
||
|
||
/**
|
||
* 实体数据映射
|
||
*
|
||
* 将原始数据映射为可供 Model 使用的数组集合
|
||
*
|
||
* @param array $raw_data 原始数据数组,通常来自 $data['raw_data']
|
||
* @return LazyCollection 返回 LazyCollection,每个元素为可供 Model::fill() 使用的数组
|
||
*/
|
||
public function entityMap(array $raw_data): LazyCollection
|
||
{
|
||
// 使用 LazyCollection 进行延迟处理,提高性能
|
||
return LazyCollection::make(function () use ($raw_data) {
|
||
// 如果 raw_data 是单个记录,转换为数组
|
||
$records = isset($raw_data[0]) ? $raw_data : [$raw_data];
|
||
|
||
foreach ($records as $record) {
|
||
|
||
$raw = \json_encode($record);
|
||
// 映射每条原始数据到 Model 可用的数组格式
|
||
yield [
|
||
'company_id' => $this->getCompany()->id,
|
||
'platform_id' => $this->getPlatform()->id,
|
||
'store_id' => $this->getStore()->id,
|
||
'order_status_id' => $this->getOrderStatusId($record['status']),
|
||
'platform_order_id' => $record['tid'],
|
||
'payment_method_id' => $this->getPaymentMethodId(),
|
||
'presale' => false,
|
||
'total_amount' => $record['total_fee'],
|
||
'total_paid' => $record['payment'],
|
||
'total_discount' => $record['discount_fee'],
|
||
// 实际收到的金额,如果平台有商家补贴,此金额需要重新计算 - 部分退款订单也影响此字段的值
|
||
'total_received' => $record['received_payment'],
|
||
'freight_fee' => 0,
|
||
'tax_fee' => 0,
|
||
'discount_fee' => 0,
|
||
'commission_fee' => 0,
|
||
'coupon_amount' => 0,
|
||
'voucher_amount' => 0,
|
||
'order_type_id' => OrderType::Normor_Order->value,
|
||
'created_date' => $record['created'],
|
||
'updated_date' => $record['modified'] ?? null,
|
||
'paid_date' => $record['pay_time'] ?? null,
|
||
'shipping_date' => $record['consign_time'] ?? null,
|
||
'zipcode' => $record['receiver_zip'] ?? '',
|
||
'city' => $record['receiver_city'] ?? '',
|
||
'province' => $record['receiver_state'] ?? '',
|
||
'country' => 'CN',
|
||
'raw' => $raw,
|
||
'ext' => null,
|
||
'buyer_user_id' => $record['buyer_open_uid'],
|
||
'hash' => \md5($raw),
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取实体的唯一键字段(用于 upsert 的 uniqueBy 参数)
|
||
*
|
||
* 定义订单的唯一约束字段组合
|
||
*
|
||
* @return array 唯一键字段名数组
|
||
*/
|
||
public function getUniqueBy(): array
|
||
{
|
||
// 天猫订单的唯一性由店铺 + 平台订单号确定(实际上由平台订单号就已经足够)
|
||
// @attention create_date 为数据库强制要求,必需携带
|
||
return ['store_id', 'platform_order_id', 'created_date'];
|
||
}
|
||
|
||
/**
|
||
* 获取可更新的字段列表(用于 upsert 的 update 参数)
|
||
*
|
||
* 明确定义哪些字段在更新时可以被修改
|
||
* 排除:主键、唯一键、创建时间、关联 ID 等不应变更的字段
|
||
*
|
||
* @return array 可更新字段名数组
|
||
*/
|
||
public function getUpdateFields(): array
|
||
{
|
||
// 方案1:手动指定可更新字段(推荐,最明确)
|
||
// 根据实际业务需求添加其他可更新字段
|
||
return [
|
||
'company_id',
|
||
'platform_id',
|
||
'store_id',
|
||
'order_status_id',
|
||
'platform_order_id',
|
||
'payment_method_id',
|
||
'presale',
|
||
'total_amount',
|
||
'total_paid',
|
||
'total_discount',
|
||
'total_received',
|
||
'freight_fee',
|
||
'tax_fee',
|
||
'discount_fee',
|
||
'commission_fee',
|
||
'coupon_amount',
|
||
'voucher_amount',
|
||
'order_type_id',
|
||
'created_date',
|
||
'updated_date',
|
||
'paid_date',
|
||
'shipping_date',
|
||
'zipcode',
|
||
'city',
|
||
'province',
|
||
'country',
|
||
'raw',
|
||
'ext',
|
||
'buyer_user_id',
|
||
'hash',
|
||
'updated_at', // 更新时刷新,created_at 不包含以保留原始创建时间
|
||
];
|
||
|
||
// 方案2:动态计算(如果字段较多且经常变动)
|
||
// $entity = $this->entityMatch($this->getData());
|
||
// $excludeFields = array_merge(
|
||
// ['id', 'created_at', 'created_date', 'company_id', 'platform_id'],
|
||
// $this->getUniqueBy()
|
||
// );
|
||
// return $this->getTableColumnsExcept($entity, $excludeFields);
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 根据淘宝订单状态确定对应的 Tools 订单状态
|
||
*
|
||
* @param string $platform_order_status 淘宝平台订单状态
|
||
* @return int Tools 订单状态 ID
|
||
*/
|
||
public function getOrderStatusId(string $platform_order_status): int
|
||
{
|
||
$status_map = $this->orderStatusMap();
|
||
|
||
return $status_map[$platform_order_status] ?? OrderStatus::PAYMENT_COMPLETE->value;
|
||
}
|
||
|
||
/**
|
||
* 淘宝订单状态到 Tools 订单状态的映射
|
||
*
|
||
* @return array<string, int>
|
||
*/
|
||
private function orderStatusMap(): array
|
||
{
|
||
return [
|
||
// Payment pending (1)
|
||
'TRADE_NO_CREATE_PAY' => OrderStatus::PAYMENT_PENDING->value, // 没有创建支付宝交易
|
||
'WAIT_BUYER_PAY' => OrderStatus::PAYMENT_PENDING->value, // 等待买家付款
|
||
'PAY_PENDING' => OrderStatus::PAYMENT_PENDING->value, // 国际信用卡支付付款确认中
|
||
'WAIT_PRE_AUTH_CONFIRM' => OrderStatus::PAYMENT_PENDING->value, // 0元购合约中
|
||
|
||
// Payment fail (2)
|
||
'TRADE_CLOSED_BY_TAOBAO' => OrderStatus::PAYMENT_FAIL->value, // 付款以前,卖家或买家主动关闭交易
|
||
|
||
// Payment complete (3)
|
||
'SELLER_CONSIGNED_PART' => OrderStatus::PAYMENT_COMPLETE->value, // 卖家部分发货
|
||
'WAIT_SELLER_SEND_GOODS' => OrderStatus::PAYMENT_COMPLETE->value, // 等待卖家发货,即:买家已付款
|
||
|
||
// Awaiting shipment (4)
|
||
'PAID_FORBID_CONSIGN' => OrderStatus::AWAITING_SHIPMENT->value, // 拼团中订单或者发货强管控的订单,已付款但禁止发货
|
||
|
||
// Shipped (5)
|
||
'WAIT_BUYER_CONFIRM_GOODS' => OrderStatus::SHIPPED->value, // 等待买家确认收货,即:卖家已发货
|
||
'TRADE_BUYER_SIGNED' => OrderStatus::SHIPPED->value, // 买家已签收,货到付款专用
|
||
|
||
// Finished (8)
|
||
'TRADE_FINISHED' => OrderStatus::FINISHED->value, // 交易成功
|
||
|
||
// Cancel before shipping (9)
|
||
'TRADE_CLOSED' => OrderStatus::CANCEL_BEFORE_SHIPPING->value, // 付款以后用户退款成功,交易自动关闭
|
||
];
|
||
}
|
||
|
||
|
||
public function getPaymentMethodId() : int
|
||
{
|
||
// @attention 暂时固定返回支付方式为支付宝, 其他平台需要定义付款方式映射
|
||
return PaymentMethod::ALIPAY_CN->value;
|
||
}
|
||
|
||
|
||
/**
|
||
*
|
||
* orderItems 的输出结果为 \App\Model\OrderItem::fill() 可以直接使用的数据格式
|
||
*
|
||
* @param array $raw_data
|
||
* @param array $platform_orders_id_to_local_db_order_id_map
|
||
|
||
* @return array
|
||
*/
|
||
public function formatOrderItemsFromRaw(array $raw_data, array $platform_orders_id_to_local_db_order_id_map): array
|
||
{
|
||
// 由于 $raw_data 是批量数据,包含多条订单结果,因此订单子项需要从集合中提取
|
||
// Tmall 平台响应体的数据格式为: collection->payload->orders
|
||
// $platform_orders_id_to_local_db_order_id_map 内部元素数据格式为 [platform_order_id => local db order id]
|
||
|
||
$items = [];
|
||
$product_keys = []; // 索引 => 查询键
|
||
|
||
foreach ($raw_data as $raw_orders) {
|
||
$parent_order_created_date = Carbon::parse($raw_orders['created']);
|
||
|
||
foreach ($raw_orders['orders']['order'] as $raw_order_item) {
|
||
// 数据库中父订单 id
|
||
$db_order_id = $platform_orders_id_to_local_db_order_id_map[$raw_orders['tid']];
|
||
$platform_order_id = strval($raw_orders['tid']);
|
||
|
||
// 记录当前索引
|
||
$index = count($items);
|
||
|
||
// 先添加 item,product_id 初始为 0
|
||
$items[] = $this->tmallOrderItemMap($raw_order_item, $platform_order_id, $db_order_id, $parent_order_created_date, 0);
|
||
|
||
// 记录该索引对应的产品查询键 (Tmall 使用 num_iid 作为 item_id,sku_id 作为 model_id)
|
||
$model_id = !empty($raw_order_item['sku_id']) ? (string)$raw_order_item['sku_id'] : null;
|
||
$product_keys[$index] = $this->buildProductMapKey((string)$raw_order_item['num_iid'], $model_id);
|
||
}
|
||
}
|
||
|
||
// 批量查询产品 ID 并更新 items
|
||
if (!empty($product_keys)) {
|
||
$products_id_map = $this->batchQueryProducts($product_keys);
|
||
foreach ($product_keys as $index => $key) {
|
||
if (isset($products_id_map[$key])) {
|
||
$items[$index]['product_id'] = $products_id_map[$key];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* 构建产品映射键
|
||
*
|
||
* @param string $item_id 平台商品 ID
|
||
* @param string|null $model_id 平台 SKU ID
|
||
* @return string
|
||
*/
|
||
private function buildProductMapKey(string $item_id, ?string $model_id): string
|
||
{
|
||
return $item_id . ':' . ($model_id ?? 'null');
|
||
}
|
||
|
||
/**
|
||
* 批量查询产品 ID
|
||
*
|
||
* 使用 PostgreSQL VALUES + JOIN 语法优化,避免生成冗长的 OR 条件链
|
||
*
|
||
* @param array $product_keys 索引 => 查询键 的映射
|
||
* @return array 查询键 => 产品 ID 的映射
|
||
*/
|
||
private function batchQueryProducts(array $product_keys): array
|
||
{
|
||
$store_id = $this->getStore()->id;
|
||
$unique_keys = array_unique(array_values($product_keys));
|
||
|
||
if (empty($unique_keys)) {
|
||
return [];
|
||
}
|
||
|
||
// 构建 VALUES 子句和绑定参数
|
||
$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;
|
||
|
||
// PostgreSQL 原生 SQL:VALUES 作为驱动表(小表在左侧)
|
||
// store_id 条件移入 JOIN ON 子句,让索引过滤更早介入
|
||
$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 = \Hyperf\DbConnection\Db::select($sql, $bindings);
|
||
|
||
$map = [];
|
||
foreach ($products as $product) {
|
||
$key = $this->buildProductMapKey($product->platform_item_id, $product->platform_model_id);
|
||
$map[$key] = $product->id;
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* Tmall 订单子项映射转换
|
||
* @return void
|
||
*/
|
||
private function tmallOrderItemMap(array $item, string $platform_order_id, int $parent_order_id, Carbon $parent_order_created_date, int $local_product_id = 0): array
|
||
{
|
||
|
||
// $item = [
|
||
// "adjust_fee" => "0.00",
|
||
// "buyer_rate" => false,
|
||
// "cid" => 50026872,
|
||
// "consign_due_time" => "2_5",
|
||
// "discount_fee" => "231.98",
|
||
// "divide_order_fee" => "504.00",
|
||
// "is_bybt_order" => false,
|
||
// "is_daixiao" => false,
|
||
// "is_idle" => "0",
|
||
// "is_jzfx" => false,
|
||
// "is_oversold" => false,
|
||
// "nr_outer_iid" => "768990037900",
|
||
// "num" => 1,
|
||
// "num_iid" => 637901668632,
|
||
// "oid" => 4932549972242808538,
|
||
// "oid_str" => "4932549972242808538",
|
||
// "order_attr" => "{"esDate":"2025-12-14","pushTime":"2025-12-09 11:30:00"}",
|
||
// "order_from" => "WAP,WAP",
|
||
// "outer_iid" => "768990037900",
|
||
// "outer_sku_id" => "768990037900",
|
||
// "part_mjz_discount" => "15.00",
|
||
// "payment" => "504.00",
|
||
// "pic_path" => "https://img.alicdn.com/bao/uploaded/i4/2413132940/O1CN01duCukP1XaZLX2jVQy_!!2413132940.jpg",
|
||
// "price" => "693.00",
|
||
// "refund_status" => "NO_REFUND",
|
||
// "s_tariff_fee" => "0.00",
|
||
// "seller_rate" => false,
|
||
// "seller_type" => "B",
|
||
// "sku_id" => "6123547511321",
|
||
// "sku_properties_name" => "颜色分类:高倍Omega鱼油软胶囊 180粒/瓶",
|
||
// "snapshot_url" => "za:4932549972242808538_1",
|
||
// "status" => "WAIT_SELLER_SEND_GOODS",
|
||
// "store_code" => "STORE_11388627",
|
||
// "sub_order_tax_fee" => "57.98",
|
||
// "sub_order_tax_promotion_fee" => "0.00",
|
||
// "sub_order_tax_rate" => "0",
|
||
// "tax_coupon_discount" => "57.98",
|
||
// "tax_free" => true,
|
||
// "title" => "【优惠价】NordicNaturals挪威小鱼dha深海鱼油Omega3成人rTG高纯度epa胶囊",
|
||
// "total_fee" => "461.02",
|
||
// ]
|
||
|
||
|
||
//@TODO order item 的业务映射需进一步补全
|
||
return [
|
||
'company_id' => $this->getCompany()->id,
|
||
'platform_id' => $this->getPlatform()->id,
|
||
'store_id' => $this->getStore()->id,
|
||
'order_id' => $parent_order_id,
|
||
'platform_order_id' => strval($platform_order_id),
|
||
'sub_order_id' => $item['oid'],
|
||
// @attention sku 的处理需要仅以规范和约束,确保 sku 准确
|
||
'sub_order_type_id' => null,
|
||
// @attention 表示未找到产品 id
|
||
'product_id' => $local_product_id,
|
||
'platform_product_id' =>$item['num_iid'],
|
||
// @attention @TODO 需要对 运营侧的产品维护做进一步规范
|
||
'product_sku' => $item['outer_sku_id'] ?? null,
|
||
// @attention @TODO 需要对 运营侧的产品维护做进一步规范
|
||
'product_barcode' => null,
|
||
'unit_price' => $item['price'],
|
||
'quantity' => $item['num'],
|
||
'discount' => $item['discount_fee'],
|
||
'total' => $item['divide_order_fee'],
|
||
// @attention 扩展字段,用来存储其他信息
|
||
'ext' => null,
|
||
'created_date' => $parent_order_created_date,
|
||
];
|
||
}
|
||
}
|