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 */ 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: 按 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; } /** * 解析退款子项的 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 */ 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); } }