mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
3157 lines
135 KiB
PHP
3157 lines
135 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\services\order;
|
||
|
||
use app\dao\order\AfterSaleDao;
|
||
use app\dao\order\OrderCenterDao;
|
||
use app\dao\order\OrderInvoiceDao;
|
||
use app\model\order\OrderCenter;
|
||
use app\model\product\product\StoreVisit;
|
||
use app\model\user\User;
|
||
use app\services\product\product\StoreProductReplyServices;
|
||
use app\services\shipping\ExpressServices;
|
||
use app\jobs\UnpaidOrderSend;
|
||
use crmeb\services\CacheService;
|
||
use crmeb\basic\BaseService;
|
||
use crmeb\trade\order\type\OrderTypeRegistry;
|
||
use crmeb\trade\order\type\OrderTypeAdapterInterface;
|
||
use crmeb\trade\order\type\OrderCenterSyncInterface;
|
||
use crmeb\trade\mode\TradeModeRegistry;
|
||
use crmeb\trade\product\ProductTypeRegistry;
|
||
use crmeb\utils\SnowflakeIdGenerator;
|
||
use crmeb\exceptions\ApiException;
|
||
use app\dao\driver\pay\PayChannelDao;
|
||
use app\dao\user\UserBillDao;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 订单中台服务层
|
||
* 提供订单中台的核心业务逻辑,包括列表查询、统计、创建、同步等功能
|
||
* 实现 OrderCenterSyncInterface 契约,供 crmeb 核心层通过接口反转依赖
|
||
*/
|
||
class OrderCenterServices extends BaseService implements OrderCenterSyncInterface
|
||
{
|
||
/**
|
||
* 数据访问层实例
|
||
* @var OrderCenterDao
|
||
*/
|
||
protected $dao;
|
||
|
||
/**
|
||
* 初始化服务
|
||
*/
|
||
protected function initialize(): void
|
||
{
|
||
$this->dao = $this->app->make(OrderCenterDao::class);
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/**
|
||
* 解析日期范围参数
|
||
* 将"2026-07-01,2026-07-13"格式转换为时间戳范围
|
||
* @param string $dateRange 日期范围字符串
|
||
* @return array 返回[开始时间戳, 结束时间戳]
|
||
* @throws \InvalidArgumentException 日期格式错误时抛出异常
|
||
*/
|
||
private function parseDateRange(string $dateRange): array
|
||
{
|
||
$parts = explode(',', $dateRange);
|
||
if (count($parts) !== 2) throw new \InvalidArgumentException('日期范围格式错误,期望格式: 2026-07-01,2026-07-13');
|
||
|
||
$start = strtotime(trim($parts[0]));
|
||
$end = strtotime(trim($parts[1]));
|
||
if ($start === false || $end === false) throw new \InvalidArgumentException('日期格式错误');
|
||
|
||
return [$start, $end + 86399];
|
||
}
|
||
|
||
/**
|
||
* 获取订单类型适配器(统一入口,含注册校验与异常兜底)
|
||
* 旧订单 source_type(如 store_order)未注册适配器时返回 null,调用方按降级逻辑处理
|
||
* @param string $sourceType 源订单类型标识
|
||
* @return OrderTypeAdapterInterface|null 适配器实例,未注册或异常返回 null
|
||
*/
|
||
protected function resolveOrderAdapter(string $sourceType): ?OrderTypeAdapterInterface
|
||
{
|
||
if ($sourceType === '' || !in_array($sourceType, OrderTypeRegistry::getRegisteredSourceTypes(), true)) {
|
||
return null;
|
||
}
|
||
try {
|
||
return OrderTypeRegistry::getAdapter($sourceType);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] resolveOrderAdapter failed: ' . $e->getMessage());
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取订单操作按钮(列表用)
|
||
* 委托给订单类型适配器,由各产品类型根据自身流程决定按钮
|
||
* @param array $order 订单数据(需包含 source_type、status_code)
|
||
* @return array 返回按钮数组
|
||
*/
|
||
protected function getOrderButtons(array $order): array
|
||
{
|
||
$adapter = $this->resolveOrderAdapter((string)($order['source_type'] ?? ''));
|
||
if (!$adapter) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
return $adapter->getStatusButtons($order['status_code'] ?? '', $order);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('getOrderButtons failed: ' . $e->getMessage());
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* 解析extra_info字段
|
||
* 兼容ORM已自动解析的JSON类型和原始字符串
|
||
* @param mixed $value JSON字符串、数组或null
|
||
* @return array 返回解析后的数组
|
||
*/
|
||
protected function parseExtraInfo($value): array
|
||
{
|
||
if ($value === null || $value === '') return [];
|
||
// ORM已自动解析JSON类型为数组
|
||
if (is_array($value)) return $value;
|
||
$decoded = json_decode($value, true);
|
||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||
\think\facade\Log::warning('extra_info JSON 解析失败: ' . json_last_error_msg());
|
||
return [];
|
||
}
|
||
return is_array($decoded) ? $decoded : [];
|
||
}
|
||
|
||
/**
|
||
* 编码extra_info字段
|
||
* 将数组转换为JSON字符串,处理编码错误
|
||
* @param mixed $value 数组或JSON字符串
|
||
* @return string|null 返回JSON字符串或null
|
||
*/
|
||
protected function encodeExtraInfo($value): ?string
|
||
{
|
||
if ($value === null || $value === []) return null;
|
||
if (is_string($value)) {
|
||
json_decode($value);
|
||
if (json_last_error() === JSON_ERROR_NONE) return $value;
|
||
}
|
||
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
return $encoded === false ? null : $encoded;
|
||
}
|
||
|
||
/**
|
||
* 补充解析 product_id
|
||
* 当 extra_info 中缺失 product_id 时(旧订单),从 order_items 或源订单表获取
|
||
* @param array $detail 订单详情数据
|
||
* @return int 返回商品ID,获取不到返回0
|
||
*/
|
||
protected function resolveProductId(array $detail): int
|
||
{
|
||
// 优先从 order_items 获取
|
||
if (!empty($detail['order_items'])) {
|
||
return (int)($detail['order_items'][0]['product_id'] ?? 0);
|
||
}
|
||
|
||
// 其次从源订单表获取(复用 getSourceOrderData)
|
||
$sourceOrder = $this->getSourceOrderData(
|
||
$detail['source_type'] ?? '',
|
||
(int)($detail['source_id'] ?? 0)
|
||
);
|
||
if ($sourceOrder) {
|
||
return (int)($sourceOrder['product_id'] ?? 0);
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* 创建订单中台记录
|
||
* 使用saveOrUpdate确保幂等性,避免重复创建
|
||
* @param array $data 订单数据
|
||
* @return bool 返回是否创建成功
|
||
*/
|
||
public function createRecord(array $data): bool
|
||
{
|
||
$extraInfo = $data['extra_info'] ?? [];
|
||
// 新订单进入统计(全部/待支付计数),清除统计缓存
|
||
$this->clearStatCache();
|
||
|
||
$insert = [
|
||
'order_no' => $data['order_no'], //
|
||
'source_type' => $data['source_type'],
|
||
'source_id' => $data['source_id'],
|
||
'uid' => $data['uid'],
|
||
'order_category' => $data['order_category'],
|
||
'product_type' => $data['product_type'] ?? '',
|
||
'activity_type' => $data['activity_type'] ?? '',
|
||
'activity_id' => $data['activity_id'] ?? 0,
|
||
'status_code' => $data['status_code'],
|
||
'pay_status' => $data['pay_status'] ?? 0,
|
||
'refund_status' => $data['refund_status'] ?? 0,
|
||
'total_price' => $data['total_price'] ?? 0,
|
||
'pay_price' => $data['pay_price'] ?? 0,
|
||
'pay_postage' => $data['pay_postage'] ?? 0,
|
||
'refund_price' => $data['refund_price'] ?? 0,
|
||
'pay_type' => $data['pay_type'] ?? '',
|
||
'coupon_price' => $data['coupon_price'] ?? '0.00',
|
||
'deduction_price' => $data['deduction_price'] ?? '0.00',
|
||
'use_integral_num' => $data['use_integral_num'] ?? 0,
|
||
'member_discount' => $data['member_discount'] ?? '0.00',
|
||
'vip_discount' => $data['vip_discount'] ?? '0.00',
|
||
'gift_price' => $data['gift_price'] ?? '0.00',
|
||
'title' => $data['title'] ?? '',
|
||
'image' => $data['image'] ?? '',
|
||
'total_num' => $data['total_num'] ?? 0,
|
||
'subtitle' => $data['subtitle'] ?? '',
|
||
'store_id' => $data['store_id'] ?? 0,
|
||
'shipping_type' => $data['shipping_type'] ?? 1,
|
||
'verify_code' => $data['verify_code'] ?? '',
|
||
'mark' => $data['mark'] ?? '',
|
||
'custom_form' => !empty($data['custom_form']) ? $this->encodeExtraInfo($data['custom_form']) : null,
|
||
'invoice' => !empty($data['invoice']) ? $this->encodeExtraInfo($data['invoice']) : null,
|
||
'extra_info' => $this->encodeExtraInfo($extraInfo),
|
||
'detail_route' => $data['detail_route'] ?? '',
|
||
'add_time' => time(),
|
||
];
|
||
|
||
$result = $this->dao->saveOrUpdate(['order_no' => $data['order_no']], $insert);
|
||
$this->clearStatCache();
|
||
return (bool) $result;
|
||
}
|
||
|
||
/**
|
||
* 订单中台统计缓存 key(带版本号:失效走版本递增,旧 key 靠自身 TTL 自然过期)
|
||
* cache 的 delete 不支持通配,此前 delete('order_center:stat:*') 为静默无效调用
|
||
* @param array $where 统计查询条件
|
||
* @return string 缓存 key
|
||
*/
|
||
protected function statCacheKey(array $where): string
|
||
{
|
||
$ver = (int) app()->cache->get('order_center:stat:ver');
|
||
|
||
return 'order_center:stat:v' . ($ver ?: 1) . ':' . md5(json_encode($where));
|
||
}
|
||
|
||
/**
|
||
* 清除订单中台统计缓存
|
||
*/
|
||
protected function clearStatCache(): void
|
||
{
|
||
app()->cache->set('order_center:stat:ver', (int) app()->cache->get('order_center:stat:ver') + 1, 86400);
|
||
}
|
||
|
||
/**
|
||
* 获取支付方式中文名称
|
||
* 从 eb_pay_channel 表动态读取,避免硬编码
|
||
* @param string $payType 支付方式 key
|
||
* @return string 返回支付方式中文名称,未匹配则返回原值
|
||
*/
|
||
protected function getPayTypeName(string $payType): string
|
||
{
|
||
static $map = [];
|
||
if (empty($map)) {
|
||
try {
|
||
$channels = app()->make(PayChannelDao::class)->getAllEnabled();
|
||
foreach ($channels as $ch) {
|
||
$map[$ch['channel_key']] = $ch['channel_name'];
|
||
}
|
||
// 内部虚拟支付方式,无对应支付渠道记录
|
||
$map['free'] = '积分兑换';
|
||
} catch (\Throwable $e) {
|
||
Log::error('[OrderCenter] 读取支付渠道失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
return $map[$payType] ?? $payType;
|
||
}
|
||
|
||
/**
|
||
* 同步订单状态
|
||
* 更新统一状态码及其他附加字段,清除统计缓存
|
||
* 订单中台记录应在订单创建流程中已写入,此处仅做更新;若记录缺失则抛异常暴露数据不一致
|
||
* @param string $orderNo 订单号
|
||
* @param string $statusCode 统一状态码
|
||
* @param array $extra 附加更新字段
|
||
* @return bool 返回是否更新成功
|
||
* @throws \RuntimeException 订单中台记录不存在时抛出
|
||
*/
|
||
public function syncStatus(string $orderNo, string $statusCode, array $extra = []): bool
|
||
{
|
||
$update = array_merge(['status_code' => $statusCode], $extra);
|
||
$centerDao = $this->dao;
|
||
$exists = $centerDao->get(['order_no' => $orderNo], ['id']);
|
||
if (!$exists) {
|
||
throw new \RuntimeException("[OrderCenter] syncStatus 失败:订单 {$orderNo} 中台记录不存在");
|
||
}
|
||
$centerDao->update(['order_no' => $orderNo], $update);
|
||
// 同步更新 extra_info.business_status,使详情页状态标签与 status_code 一致
|
||
$statusText = OrderCenter::$statusMap[$statusCode] ?? $statusCode;
|
||
$currentExtra = $this->dao->getDb()->where('order_no', $orderNo)->value('extra_info');
|
||
$extraArray = $this->parseExtraInfo($currentExtra);
|
||
$extraArray['business_status'] = [
|
||
'code' => $statusCode,
|
||
'text' => $statusText,
|
||
'detail' => '',
|
||
'extend' => new \stdClass(),
|
||
];
|
||
$this->dao->update(['order_no' => $orderNo], [
|
||
'extra_info' => $this->encodeExtraInfo($extraArray),
|
||
]);
|
||
$this->clearStatCache();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 更新订单中台记录
|
||
* 通用更新方法,清除统计缓存
|
||
* @param string $orderNo 订单号
|
||
* @param array $data 更新数据
|
||
* @return bool 返回是否更新成功
|
||
*/
|
||
public function updateRecord(string $orderNo, array $data): bool
|
||
{
|
||
$result = $this->dao->update(['order_no' => $orderNo], $data) !== false;
|
||
$this->clearStatCache();
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 订单评价完成后检查
|
||
* 订单内全部商品均已评价且当前处于待评价状态时,流转为已完成
|
||
* @param int $orderId 订单中台 ID
|
||
* @return bool 是否触发了状态流转
|
||
*/
|
||
public function checkOrderOver(int $orderId): bool
|
||
{
|
||
$order = $this->dao->get((int)$orderId, ['id', 'order_no', 'source_type', 'status_code', 'extra_info']);
|
||
if (!$order) {
|
||
return false;
|
||
}
|
||
$order = is_object($order) ? $order->toArray() : (array)$order;
|
||
if (($order['status_code'] ?? '') !== OrderCenter::STATUS_PENDING_REVIEW) {
|
||
return false;
|
||
}
|
||
$extra = $this->parseExtraInfo($order['extra_info'] ?? null);
|
||
$allUniques = [];
|
||
foreach ($extra['items'] ?? [] as $item) {
|
||
$unique = $item['attr_info']['unique'] ?? ($item['unique'] ?? '');
|
||
if ($unique !== '') {
|
||
$allUniques[] = (string)$unique;
|
||
}
|
||
}
|
||
if (empty($allUniques)) {
|
||
return false;
|
||
}
|
||
/** @var \app\services\product\product\StoreProductReplyServices $replyServices */
|
||
$replyServices = $this->app->make(\app\services\product\product\StoreProductReplyServices::class);
|
||
$replied = array_unique($replyServices->getRepliedUniques($allUniques));
|
||
if (count($replied) < count(array_unique($allUniques))) {
|
||
return false;
|
||
}
|
||
return $this->completeOrderStatus($order);
|
||
}
|
||
|
||
protected function completeOrderStatus(array $order): bool
|
||
{
|
||
try {
|
||
OrderTypeRegistry::getAdapter((string)($order['source_type'] ?? ''))
|
||
->advance((string)$order['order_no'], OrderCenter::STATUS_COMPLETED);
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] complete source order failed: ' . $e->getMessage());
|
||
return $this->syncStatus((string)$order['order_no'], OrderCenter::STATUS_COMPLETED);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从源订单同步到订单中台
|
||
* 根据source_type自动推导状态,支持自定义数据构建回调
|
||
* @param string $sourceType 源表类型标识
|
||
* @param array $sourceOrder 源订单数据
|
||
* @param callable|null $buildData 自定义数据构建回调
|
||
* @return bool 返回是否同步成功
|
||
*/
|
||
public function syncFromSource(string $sourceType, array $sourceOrder, ?callable $buildData = null): bool
|
||
{
|
||
if ($buildData) {
|
||
$data = $buildData($sourceOrder);
|
||
} else {
|
||
$data = $this->buildDefaultData($sourceType, $sourceOrder);
|
||
}
|
||
|
||
$exists = $this->dao->get(['order_no' => $data['order_no']]);
|
||
if ($exists) {
|
||
return $this->updateRecord($data['order_no'], $data);
|
||
}
|
||
return $this->createRecord($data);
|
||
}
|
||
|
||
/**
|
||
* 构建默认同步数据
|
||
* 通过状态注册器自动推导统一状态码、支付状态、退款状态
|
||
* @param string $sourceType 源表类型标识
|
||
* @param array $sourceOrder 源订单数据
|
||
* @return array 返回构建的数据数组
|
||
*/
|
||
protected function buildDefaultData(string $sourceType, array $sourceOrder): array
|
||
{
|
||
return [
|
||
'order_no' => $sourceOrder['order_no'] ?? $sourceOrder['order_id'] ?? '',
|
||
'source_type' => $sourceType,
|
||
'source_id' => $sourceOrder['id'] ?? 0,
|
||
'uid' => $sourceOrder['uid'] ?? 0,
|
||
'status_code' => OrderTypeRegistry::resolveStatusCode($sourceType, $sourceOrder),
|
||
'pay_status' => OrderTypeRegistry::resolvePayStatus($sourceType, $sourceOrder),
|
||
'refund_status' => OrderTypeRegistry::resolveRefundStatus($sourceType, $sourceOrder),
|
||
'total_price' => $sourceOrder['total_price'] ?? 0,
|
||
'pay_price' => $sourceOrder['pay_price'] ?? $sourceOrder['price'] ?? 0,
|
||
'pay_postage' => $sourceOrder['pay_postage'] ?? 0,
|
||
'pay_type' => $sourceOrder['pay_type'] ?? '',
|
||
'add_time' => $sourceOrder['add_time'] ?? time(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 根据订单号获取订单详情
|
||
* @param string $orderNo 订单号
|
||
* @param bool $withRemoteLogistics 是否同步请求第三方物流轨迹
|
||
* @return array 返回订单详情数据
|
||
* @throws \RuntimeException 订单不存在时抛出异常
|
||
*/
|
||
public function getDetailByOrderNo(string $orderNo, bool $withRemoteLogistics = true): array
|
||
{
|
||
$detail = $this->dao->get(['order_no' => $orderNo], ['id']);
|
||
if (!$detail) {
|
||
throw new \RuntimeException('订单不存在');
|
||
}
|
||
return $this->getDetail((int)$detail['id'], $withRemoteLogistics);
|
||
}
|
||
|
||
/**
|
||
* 获取订单详情
|
||
* 通过事件机制允许扩展模块注入详情数据
|
||
* @param int $id 订单中台记录ID
|
||
* @param bool $withRemoteLogistics 是否同步请求第三方物流轨迹(详情首屏可关闭,改由前端异步查询)
|
||
* @return array 返回订单详情数据
|
||
* @throws \RuntimeException 订单不存在时抛出异常
|
||
*/
|
||
public function getDetail(int $id, bool $withRemoteLogistics = true): array
|
||
{
|
||
$detail = $this->dao->get(['id' => $id]);
|
||
if (!$detail) {
|
||
throw new \RuntimeException('订单不存在');
|
||
}
|
||
$detail = $detail->toArray();
|
||
$detail['extra_info'] = $this->parseExtraInfo($detail['extra_info']);
|
||
// 下单用户昵称/绑定电话/头像(eb_user),供详情"用户信息"与送礼信息区展示
|
||
$detailUser = User::where('uid', (int)$detail['uid'])->field('nickname,phone,avatar')->find();
|
||
$detail['nickname'] = $detailUser['nickname'] ?? '';
|
||
$detail['user_phone'] = $detailUser['phone'] ?? '';
|
||
$detail['user_avatar'] = set_file_url((string)($detailUser['avatar'] ?? ''));
|
||
// 送礼订单:领取人信息(gift_uid 非 0 时),供送礼人查看"谁领取了礼物"
|
||
$giftUid = (int)($detail['gift_uid'] ?? $detail['extra_info']['gift']['gift_uid'] ?? ($detail['extra_info']['gift_uid'] ?? 0));
|
||
if ($giftUid > 0) {
|
||
$receiver = User::where('uid', $giftUid)->field('nickname,avatar')->find();
|
||
$detail['gift_receiver'] = [
|
||
'uid' => $giftUid,
|
||
'nickname' => (string)($receiver['nickname'] ?? ''),
|
||
'avatar' => set_file_url((string)($receiver['avatar'] ?? '')),
|
||
];
|
||
}
|
||
// 解析 JSON 字段
|
||
if (!empty($detail['custom_form'])) {
|
||
$detail['custom_form'] = $this->parseExtraInfo($detail['custom_form']);
|
||
$detail['custom_form'] = $this->transformCustomForm($detail['custom_form']);
|
||
}
|
||
// 解析发票信息:中台 invoice 仅是下单时抬头快照,不含开票状态;
|
||
// 开票状态(is_invoice/票号/开票时间)以申请表 eb_order_invoice 为真源,后台开票只写该表
|
||
$invoiceSnapshot = [];
|
||
if (!empty($detail['invoice'])) {
|
||
// 中台订单创建时保存的是用户发票原始字段(name/duty_number),
|
||
// 详情契约统一使用 title/tax_number,避免前端因数据来源不同而漏显示。
|
||
$invoiceSnapshot = $this->normalizeInvoice($this->parseExtraInfo($detail['invoice']));
|
||
}
|
||
// 支付方式从 eb_pay_channel 读取中文名称
|
||
$detail['pay_type_name'] = $this->getPayTypeName($detail['pay_type']);
|
||
// 实际退款金额:多端详情页契约字段 refunded_price(中台表无此列,按退款累计列输出)
|
||
// 真源是 refund_price(退款成功时由 RefundEffectServices 按退款单金额累计维护,含多次部分退款)
|
||
$detail['refunded_price'] = (string)($detail['refund_price'] ?? '0.00');
|
||
|
||
// 从源订单表加载发票、备注、自定义表单等信息,并重新推导最新状态码和扩展状态
|
||
// 必须在构建时间线、按钮、进度条之前调用,确保这些字段基于实时状态
|
||
$this->loadSourceOrderInfo($detail);
|
||
|
||
// 发票信息:优先取申请表(含开票状态),无申请记录时回退下单快照
|
||
$invoiceLive = $this->loadOrderInvoice((int)$detail['id']);
|
||
if (!empty($invoiceLive)) {
|
||
// 快照可补齐申请表未存的展示字段,但状态类字段必须以申请表为准
|
||
$detail['invoice'] = array_merge($invoiceSnapshot, $invoiceLive);
|
||
} else {
|
||
$detail['invoice'] = $invoiceSnapshot ?: null;
|
||
}
|
||
|
||
// loadSourceOrderInfo 可能重算 status_code,需同步刷新 status_text
|
||
// 确保 buildGroupedData 和前端引擎都能拿到正确的状态文案
|
||
$detail['status_text'] = OrderCenter::$statusMap[$detail['status_code'] ?? ''] ?? ($detail['status_code'] ?? '');
|
||
|
||
// 用户已删除:详情状态统一展示「已删除」(is_del 为软删标记,不改写 status_code)
|
||
if ((int)($detail['is_del'] ?? 0) === 1) {
|
||
$detail['status_text'] = '已删除';
|
||
$detail['business_status'] = [
|
||
'code' => 'deleted',
|
||
'label' => '已删除',
|
||
'text' => '已删除',
|
||
'type' => 'danger',
|
||
];
|
||
}
|
||
|
||
// 构建操作按钮(委托给产品类型 Handler)
|
||
$detail['order_buttons'] = $this->getOrderButtonsFromHandler($detail);
|
||
// 构建订单生命周期进度条(不同订单类型节点不同,委托给产品类型 Handler)
|
||
$detail['progress'] = $this->getProgressFromHandler($detail);
|
||
|
||
// 构建订单时间线(从 eb_order_log 表读取操作记录,最新事件在前)
|
||
$detail['timeline'] = $this->buildTimeline($detail);
|
||
|
||
// 前端统一的到店自提标识
|
||
$detail['is_pickup'] = ($detail['shipping_type'] ?? 0) == 2;
|
||
// 前端统一的上门服务标识
|
||
$detail['is_door_service'] = ($detail['shipping_type'] ?? 0) == 3;
|
||
// 到店自提订单:从 order_center 表 store_id 列加载门店信息
|
||
$storeId = (int)($detail['store_id'] ?? 0);
|
||
if ($detail['is_pickup'] && $storeId > 0) {
|
||
try {
|
||
/** @var \app\services\system\store\SystemStoreServices $storeServices */
|
||
$storeServices = app()->make(\app\services\system\store\SystemStoreServices::class);
|
||
$storeInfo = $storeServices->getStoreDispose($storeId);
|
||
if ($storeInfo) {
|
||
$detail['store'] = is_object($storeInfo) ? $storeInfo->toArray() : $storeInfo;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] load store info failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
// 非实物商品(虚拟/卡密)无物流配送概念,清除 store 信息,前端无需展示
|
||
// contact/delivery/service 等扩展独有字段由各产品类型 OrderCenterDetailExtendListener 注入
|
||
$typeHandler = null;
|
||
try { $typeHandler = app()->make(ProductTypeRegistry::class)->get((string)($detail['product_type'] ?? '')); } catch (\Throwable $e) {}
|
||
$capabilities = $typeHandler ? $typeHandler->getCapabilities() : [];
|
||
$isVirtual = (($capabilities['order']['virtual'] ?? false) === true);
|
||
if ($isVirtual || !$typeHandler) {
|
||
unset($detail['store']);
|
||
$detail['is_pickup'] = false;
|
||
$detail['is_door_service'] = false;
|
||
}
|
||
// 从实体列读取核心金额字段,旧数据 fallback 到 extra_info
|
||
// 新订单:6 个金额字段写入实体列(coupon_price/deduction_price/use_integral_num/
|
||
// member_discount/vip_discount/gift_price),extra_info 中不再双写
|
||
// 旧订单:extra_info 中留有旧数据,实体列值为 0.00,需 fallback
|
||
$priceFields = [
|
||
'coupon_price' => 'coupon_price',
|
||
'deduction_price' => 'deduction_price',
|
||
'use_integral_num' => 'use_integral_num',
|
||
'member_discount' => 'member_level_discount', // 列名 ≠ extra_info key
|
||
'vip_discount' => 'vip_discount',
|
||
'gift_price' => 'gift_price',
|
||
];
|
||
foreach ($priceFields as $entityField => $extraInfoKey) {
|
||
$entityVal = $detail[$entityField] ?? null;
|
||
// 实体列值为默认值(新增列后旧订单为 0.00/0)且 extra_info 有旧数据 → 使用旧数据
|
||
$isDefault = ($entityVal === null || $entityVal === 0 || $entityVal === '0' || $entityVal === '0.00');
|
||
if ($isDefault && isset($detail['extra_info'][$extraInfoKey])) {
|
||
$detail[$entityField] = $detail['extra_info'][$extraInfoKey];
|
||
}
|
||
unset($detail['extra_info'][$extraInfoKey]);
|
||
}
|
||
// 从 extra_info 中提取业务状态到顶层
|
||
// loadSourceOrderInfo 已按扩展表实时推导 business_status(优先采用),
|
||
// 仅当推导结果为空(如已取消/已退款无业务子状态)时才回退 extra_info 中的快照,
|
||
// 避免残留的旧快照覆盖实时推导值(如门票 issued 后应显示"待使用"而非旧"已完成")
|
||
if (empty($detail['business_status']) && isset($detail['extra_info']['business_status'])) {
|
||
$detail['business_status'] = $detail['extra_info']['business_status'];
|
||
}
|
||
unset($detail['extra_info']['business_status']);
|
||
|
||
// 加载商品明细(order_items)
|
||
$detail['order_items'] = $this->loadOrderItems($detail);
|
||
|
||
// 补充 extra_info 中缺失的 product_id
|
||
// 旧订单创建时可能未存储 product_id,从源订单表或 order_items 获取
|
||
if (!isset($detail['extra_info']['product_id']) || empty($detail['extra_info']['product_id'])) {
|
||
$detail['extra_info']['product_id'] = $this->resolveProductId($detail);
|
||
}
|
||
|
||
// 根据产品类型生成后台详情路由,供前端 iframe 嵌入
|
||
$detail['admin_detail_url'] = $this->getAdminDetailUrl($detail);
|
||
|
||
// 初始化扩展容器,供插件注入数据
|
||
// extend:扩展数据(合并进 page_config.extensions)
|
||
// extend_sections:插件自声明的渲染 section(追加进 page_config.sections)
|
||
$detail['extend'] = [];
|
||
$detail['extend_sections'] = [];
|
||
|
||
// 通过事件机制,允许扩展模块注入详情数据
|
||
$eventData = [
|
||
'detail' => &$detail,
|
||
'source_type' => $detail['source_type'],
|
||
'source_id' => $detail['source_id'],
|
||
];
|
||
// 触发事件,允许插件注入数据
|
||
event('OrderCenterDetailExtend', $eventData);
|
||
|
||
// 加载售后进度扩展数据(含 type_text/status_text/timeline)
|
||
// 前端 AfterSaleProgress 组件据此渲染,不再硬编码售后类型映射表
|
||
$this->loadAfterSaleExtension($detail);
|
||
|
||
// 加载物流轨迹扩展数据(已发货订单调用物流查询驱动获取 tracks)
|
||
// 前端 LogisticsTimeline 组件据此渲染物流轨迹卡片,无需点"查看物流"单独查询
|
||
$this->loadLogisticsExtension($detail, $withRemoteLogistics);
|
||
|
||
// 加载订单完成态权益扩展数据(积分奖励/领券入口,系统内置能力,中台自行组装)
|
||
$this->loadOrderRewardExtension($detail);
|
||
|
||
// 如果没有任何插件注入数据,移除 extend 字段
|
||
if (empty($detail['extend'])) {
|
||
unset($detail['extend']);
|
||
}
|
||
// 如果没有任何插件声明渲染 section,移除 extend_sections 字段
|
||
if (empty($detail['extend_sections'])) {
|
||
unset($detail['extend_sections']);
|
||
}
|
||
|
||
return $detail;
|
||
}
|
||
|
||
/**
|
||
* 判定是否送礼订单
|
||
* extra_info.gift / gift_mark 为下单写入的送礼标记,gift_price 为兼容旧单兜底
|
||
* @param array $detail 订单详情(含 extra_info)
|
||
* @return int 1=送礼订单 0=普通订单
|
||
*/
|
||
protected function resolveIsGiftFlag(array $detail): int
|
||
{
|
||
$extra = $detail['extra_info'] ?? [];
|
||
if (!is_array($extra)) {
|
||
$extra = [];
|
||
}
|
||
if (!empty($extra['gift']) || isset($extra['gift_mark'])) {
|
||
return 1;
|
||
}
|
||
return (float)($detail['gift_price'] ?? 0) > 0 ? 1 : 0;
|
||
}
|
||
|
||
/**
|
||
* 计算订单商品原价总额(规格价 × 数量)
|
||
* total_price 为优惠后金额,价格明细中的“商品总额”需使用原价基数,才能与各项优惠对平
|
||
* @param array $detail 订单详情数据
|
||
* @return string 金额字符串(两位小数)
|
||
*/
|
||
protected function resolveOriginalGoodsTotal(array $detail): string
|
||
{
|
||
$total = '0.00';
|
||
foreach (($detail['order_items'] ?? []) as $item) {
|
||
if (!is_array($item)) {
|
||
continue;
|
||
}
|
||
$price = is_array($item['attr_info'] ?? null) ? ($item['attr_info']['price'] ?? '') : '';
|
||
// 订单快照 item.price 可能是商品级价格,规格金额以 attr_info.price 为准
|
||
if ($price === '' || $price === null) {
|
||
$price = $item['price'] ?? '';
|
||
}
|
||
if ($price === '' || !is_numeric($price)) {
|
||
continue;
|
||
}
|
||
$num = max(1, (int)($item['cart_num'] ?? 1));
|
||
$total = bcadd($total, bcmul((string)$price, (string)$num, 2), 2);
|
||
}
|
||
if (bccomp($total, '0', 2) <= 0) {
|
||
$total = number_format((float)($detail['total_price'] ?? 0), 2, '.', '');
|
||
}
|
||
return $total;
|
||
}
|
||
|
||
/**
|
||
* 组装价格明细数组
|
||
* 将金额实体列转为 {label, value, type} 数组格式
|
||
* 金额字段为中台实体列(价格链规范固定);插件可通过 OrderPricingExtend 事件追加金额项
|
||
* @param array $detail 订单详情数据
|
||
* @return array 返回 {items: [...], pay_price: string, original_total: string}
|
||
*/
|
||
protected function buildPricingItems(array $detail): array
|
||
{
|
||
$items = [];
|
||
// 商品总额按规格原价 × 数量汇总(total_price 是优惠后金额,不能作为明细基数)
|
||
$originalTotal = $this->resolveOriginalGoodsTotal($detail);
|
||
$items[] = [
|
||
'label' => '商品总额',
|
||
'value' => $originalTotal,
|
||
'type' => 'subtotal',
|
||
];
|
||
|
||
// 快递运费:展示优惠前金额(pay_postage 为会员运费优惠后的实付运费)
|
||
$postage = (float)($detail['pay_postage'] ?? 0);
|
||
$extraInfo = $detail['extra_info'] ?? [];
|
||
if (!is_array($extraInfo)) {
|
||
$extraInfo = [];
|
||
}
|
||
$postageDiscount = (float)($extraInfo['postage_discount'] ?? 0);
|
||
$originalPostage = (float)bcadd((string)$postage, (string)$postageDiscount, 2);
|
||
if ($originalPostage > 0) {
|
||
$items[] = [
|
||
'label' => '快递运费',
|
||
'value' => '+' . number_format($originalPostage, 2, '.', ''),
|
||
'type' => 'shipping',
|
||
];
|
||
}
|
||
// 会员运费优惠单独展示,与订单确认页口径一致
|
||
if ($postageDiscount > 0) {
|
||
$items[] = [
|
||
'label' => '会员运费优惠',
|
||
'value' => '-' . number_format($postageDiscount, 2, '.', ''),
|
||
'type' => 'shipping_discount',
|
||
];
|
||
}
|
||
// 优惠券抵扣
|
||
if (!empty($detail['coupon_price']) && (float)$detail['coupon_price'] > 0) {
|
||
$items[] = [
|
||
'label' => '优惠券抵扣',
|
||
'value' => '-' . number_format((float)$detail['coupon_price'], 2, '.', ''),
|
||
'type' => 'coupon',
|
||
];
|
||
}
|
||
// 积分抵扣
|
||
if (!empty($detail['deduction_price']) && (float)$detail['deduction_price'] > 0) {
|
||
$items[] = [
|
||
'label' => '积分抵扣',
|
||
'value' => '-' . number_format((float)$detail['deduction_price'], 2, '.', ''),
|
||
'type' => 'integral',
|
||
];
|
||
}
|
||
// 等级会员优惠
|
||
if (!empty($detail['member_discount']) && (float)$detail['member_discount'] > 0) {
|
||
$items[] = [
|
||
'label' => '会员折扣',
|
||
'value' => '-' . number_format((float)$detail['member_discount'], 2, '.', ''),
|
||
'type' => 'member',
|
||
];
|
||
}
|
||
// 付费会员优惠
|
||
if (!empty($detail['vip_discount']) && (float)$detail['vip_discount'] > 0) {
|
||
$items[] = [
|
||
'label' => '付费会员优惠',
|
||
'value' => '-' . number_format((float)$detail['vip_discount'], 2, '.', ''),
|
||
'type' => 'vip',
|
||
];
|
||
}
|
||
// 礼品附加费(送礼订单按件累计)
|
||
if (!empty($detail['gift_price']) && (float)$detail['gift_price'] > 0) {
|
||
$items[] = [
|
||
'label' => '礼品附加费',
|
||
'value' => '+' . number_format((float)$detail['gift_price'], 2, '.', ''),
|
||
'type' => 'gift',
|
||
];
|
||
}
|
||
|
||
// 价格明细扩展缝口:交易模式/产品类型插件经事件追加金额项(如服务费/上门费)
|
||
$pricingEvent = ['items' => &$items, 'detail' => $detail];
|
||
event('OrderPricingExtend', $pricingEvent);
|
||
|
||
return [
|
||
'items' => $items,
|
||
'pay_price' => number_format((float)($detail['pay_price'] ?? 0), 2, '.', ''),
|
||
'original_total' => $originalTotal,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 组装默认 page_config sections 声明式渲染配置
|
||
* 只组装中台自身职责的 section(状态/商品/价格/配送/核销/表单/发票/售后等);
|
||
* 插件专属 section 由插件在 OrderCenterDetailExtend 监听器中通过 extend_sections 自声明,
|
||
* 中台不感知插件业务,新增插件零修改中台代码(架构铁律:中台不处理扩展层独有字段)
|
||
* @param array $detail 订单详情数据(已组装完成)
|
||
* @param array $extendSections 插件自声明的渲染 section(key => section 配置)
|
||
* @return array 返回 {sections: [...]}
|
||
*/
|
||
protected function buildDefaultPageConfig(array $detail, array $extendSections = []): array
|
||
{
|
||
// $detail 为 buildGroupedData 的分组结构,从各功能域分组读取
|
||
$statusCode = $detail['status']['code'] ?? '';
|
||
$isPickup = ($detail['delivery']['shipping_type'] ?? 0) == 2;
|
||
$productType = (string)($detail['order_info']['product_type'] ?? '');
|
||
$sections = [];
|
||
|
||
// 10. 状态头部(始终存在)
|
||
// props 直填渲染值(v8.1 简化契约:去掉 JSONPath,前端引擎无需二次解析)
|
||
// code/pay_status 供前端引擎逻辑(isRefund/payStatus)读取
|
||
$sections[] = [
|
||
'key' => 'status',
|
||
'component' => 'status_bar',
|
||
'props' => [
|
||
'text' => $detail['status']['text'] ?? '',
|
||
'icon' => $detail['status']['icon'] ?? '',
|
||
'subtitle' => $detail['status']['subtitle'] ?? '',
|
||
'add_time' => $detail['order_info']['add_time'] ?? 0,
|
||
'code' => $detail['status']['code'] ?? '',
|
||
'pay_status' => $detail['status']['pay_status'] ?? 0,
|
||
],
|
||
'visible' => true,
|
||
'order' => 10,
|
||
];
|
||
|
||
// 15. 待支付倒计时(仅 pending_payment)
|
||
$countdown = $detail['countdown'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'countdown',
|
||
'component' => 'countdown',
|
||
'props' => [
|
||
'end_time' => $countdown['end_time'] ?? 0,
|
||
'remaining' => $countdown['remaining'] ?? 0,
|
||
'text' => $countdown['text'] ?? '',
|
||
],
|
||
'visible' => $statusCode === 'pending_payment',
|
||
'order' => 15,
|
||
];
|
||
|
||
// 20. 进度条(非售后/退款/取消)
|
||
$progressVisible = !in_array($statusCode, ['after_sale', 'refunded', 'canceled']);
|
||
$progress = $detail['status']['progress'] ?? [];
|
||
$sections[] = [
|
||
'key' => 'progress',
|
||
'component' => 'progress_bar',
|
||
'props' => [
|
||
'nodes' => $progress['nodes'] ?? [],
|
||
'current' => $progress['current'] ?? 0,
|
||
],
|
||
'visible' => $progressVisible,
|
||
'order' => 20,
|
||
];
|
||
|
||
// 25. 提示条
|
||
$sections[] = [
|
||
'key' => 'notice',
|
||
'component' => 'notice_bar',
|
||
'props' => ['notices' => $detail['notice'] ?? []],
|
||
'visible' => !empty($detail['notice']),
|
||
'order' => 25,
|
||
];
|
||
|
||
// 30. 活动进度(拼团/砍价/秒杀等)
|
||
// title 由后端动态生成:活动扩展自带 title 优先,否则用交易模式短标签 + "进度" 兜底
|
||
$activityExt = $detail['extensions']['activity'] ?? null;
|
||
$activityTitle = '';
|
||
if (!empty($activityExt)) {
|
||
$activityTitle = (string)($activityExt['title'] ?? '');
|
||
if ($activityTitle === '') {
|
||
$activityType = $activityExt['type'] ?? ($detail['order_info']['activity_type'] ?? '');
|
||
if ($activityType && $activityType !== 'normal') {
|
||
try {
|
||
$tradeModeRegistry = app()->make(\crmeb\trade\mode\TradeModeRegistry::class);
|
||
if ($tradeModeRegistry->has($activityType)) {
|
||
$activityTitle = $tradeModeRegistry->get($activityType)->getLabel() . '进度';
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 交易模式未注册时静默降级,标题留空由前端兜底
|
||
}
|
||
}
|
||
}
|
||
}
|
||
$sections[] = [
|
||
'key' => 'activity',
|
||
'component' => 'activity_card',
|
||
'props' => [
|
||
'activity' => $activityExt,
|
||
'title' => $activityTitle,
|
||
],
|
||
'visible' => !empty($activityExt),
|
||
'order' => 30,
|
||
];
|
||
|
||
// 40. 配送/自提卡片(仅实物商品)
|
||
// 上门服务订单不渲染地址卡片,预约信息由 service_appointment section(118)展示
|
||
// 送礼订单的可见性由查看者身份决定(购买者隐藏、领取人展示),在 applyGiftViewerPolicy 中裁剪
|
||
if ($productType === 'normal' && !($detail['delivery']['is_door_service'] ?? false)) {
|
||
$deliveryComponent = $isPickup ? 'store_card' : 'address_card';
|
||
$deliveryProps = $isPickup
|
||
? [
|
||
'store' => $detail['delivery']['store'] ?? null,
|
||
'verify_code' => $detail['verification']['code'] ?? '',
|
||
]
|
||
: [
|
||
'contact' => $detail['delivery']['contact'] ?? null,
|
||
'user_address' => $detail['delivery']['user_address'] ?? '',
|
||
];
|
||
$sections[] = [
|
||
'key' => 'delivery',
|
||
'component' => $deliveryComponent,
|
||
'props' => $deliveryProps,
|
||
'visible' => true,
|
||
'order' => 40,
|
||
];
|
||
}
|
||
|
||
// 45. 礼物信息(送礼订单):赠送人/领取人展示由查看者身份决定,在 applyGiftViewerPolicy 中填充
|
||
$gift = $detail['gift'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'gift',
|
||
'component' => 'gift_info',
|
||
'props' => ['gift' => $gift ?? []],
|
||
'visible' => !empty($gift),
|
||
'order' => 45,
|
||
];
|
||
|
||
// 50. 商品卡片(始终存在)
|
||
// product_id 供前端引擎跳转商品详情(goProductDetail)使用
|
||
$product = $detail['product'] ?? [];
|
||
$productId = $detail['extensions']['extra_info']['product_id'] ?? ($product['items'][0]['product_id'] ?? 0);
|
||
$sections[] = [
|
||
'key' => 'product',
|
||
'component' => 'product_card',
|
||
'props' => [
|
||
'product' => $product,
|
||
'product_id' => (int)$productId,
|
||
],
|
||
'visible' => true,
|
||
'order' => 50,
|
||
];
|
||
|
||
// 65. 核销码(有核销码的订单:自提订单 或 已支付的非自提订单如酒店预订)
|
||
// 可见性由 buildGroupedData 中 verification 对象是否构造决定,此处只需检查 code 是否存在
|
||
$verificationVisible = !empty($detail['verification']['code']);
|
||
$verification = $detail['verification'] ?? [];
|
||
$sections[] = [
|
||
'key' => 'verification',
|
||
'component' => 'qrcode',
|
||
'props' => [
|
||
'code' => $verification['code'] ?? '',
|
||
'qrcode_url' => $verification['qrcode_url'] ?? '',
|
||
'status' => $verification['status'] ?? 'pending',
|
||
],
|
||
'visible' => $verificationVisible,
|
||
'order' => 65,
|
||
];
|
||
|
||
// 70. 自定义表单
|
||
$customForm = $detail['extensions']['custom_form'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'custom_form',
|
||
'component' => 'custom_form',
|
||
'props' => ['items' => $customForm ?? []],
|
||
'visible' => !empty($customForm),
|
||
'order' => 70,
|
||
];
|
||
|
||
// 75. 物流轨迹
|
||
$logisticsExt = $detail['extensions']['logistics'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'logistics',
|
||
'component' => 'timeline',
|
||
'props' => [
|
||
'tracks' => $logisticsExt['tracks'] ?? [],
|
||
'delivery_name' => $logisticsExt['delivery_name'] ?? '',
|
||
'delivery_id' => $logisticsExt['delivery_id'] ?? '',
|
||
],
|
||
'visible' => !empty($logisticsExt),
|
||
'order' => 75,
|
||
];
|
||
|
||
// 80. 价格明细(始终存在)
|
||
$sections[] = [
|
||
'key' => 'pricing',
|
||
'component' => 'price_detail',
|
||
'props' => [
|
||
'items' => $detail['pricing']['items'] ?? [],
|
||
'pay_price' => $detail['pricing']['pay_price'] ?? '0.00',
|
||
],
|
||
'visible' => true,
|
||
'order' => 80,
|
||
];
|
||
|
||
// 85. 订单信息(始终存在)
|
||
$sections[] = [
|
||
'key' => 'order_info',
|
||
'component' => 'info_list',
|
||
'props' => ['fields' => $detail['order_info'] ?? [], 'title' => '订单信息'],
|
||
'visible' => true,
|
||
'order' => 85,
|
||
];
|
||
|
||
// 90. 发票信息(从 extensions.invoice 取值,由 loadOrderInvoice 组装)
|
||
$invoiceExt = $detail['extensions']['invoice'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'invoice',
|
||
'component' => 'info_list',
|
||
'props' => ['fields' => $invoiceExt ?? [], 'title' => '发票信息'],
|
||
'visible' => !empty($invoiceExt),
|
||
'order' => 90,
|
||
];
|
||
|
||
// 93. 支付详情(已支付订单)
|
||
$payment = $detail['payment'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'payment',
|
||
'component' => 'info_list',
|
||
'props' => ['fields' => $payment ?? [], 'title' => '支付详情'],
|
||
'visible' => !empty($payment),
|
||
'order' => 93,
|
||
];
|
||
|
||
// 100. 售后进度
|
||
$afterSaleExt = $detail['extensions']['after_sale'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'after_sale',
|
||
'component' => 'after_sale_progress',
|
||
'props' => ['after_sale' => $afterSaleExt ?? []],
|
||
'visible' => !empty($afterSaleExt),
|
||
'order' => 100,
|
||
];
|
||
|
||
// 105. 关联订单(换货衍生单)
|
||
$relatedOrders = $detail['related_orders'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'related_orders',
|
||
'component' => 'related_order_list',
|
||
'props' => ['items' => $relatedOrders ?? []],
|
||
'visible' => !empty($relatedOrders),
|
||
'order' => 105,
|
||
];
|
||
|
||
// 122. 领券入口(订单完成,系统内置权益,跳转领券中心)
|
||
$couponGiftExt = $detail['extensions']['coupon_gift'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'coupon_gift',
|
||
'component' => 'action_card',
|
||
'props' => [
|
||
'title' => $couponGiftExt['title'] ?? '领取优惠券',
|
||
'icon' => 'icon-youhuiquan',
|
||
'action' => 'goCouponGift',
|
||
'link' => $couponGiftExt['link'] ?? '',
|
||
],
|
||
'visible' => !empty($couponGiftExt),
|
||
'order' => 122,
|
||
];
|
||
|
||
// 126. 积分奖励(订单完成且本单有积分入账,系统内置权益)
|
||
$pointsRewardExt = $detail['extensions']['points_reward'] ?? null;
|
||
$sections[] = [
|
||
'key' => 'points_reward',
|
||
'component' => 'action_card',
|
||
'props' => [
|
||
'title' => $pointsRewardExt['text'] ?? '',
|
||
'icon' => 'icon-jifen',
|
||
'action' => 'goPointsReward',
|
||
'link' => $pointsRewardExt['link'] ?? '',
|
||
],
|
||
'visible' => !empty($pointsRewardExt),
|
||
'order' => 126,
|
||
];
|
||
|
||
// 128. 客服联系(受中台系统配置 customer_service_switch 控制,默认显示)
|
||
$sections[] = [
|
||
'key' => 'service_contact',
|
||
'component' => 'action_card',
|
||
'props' => ['title' => '联系客服', 'icon' => 'icon-kefu', 'action' => 'goGoodCall'],
|
||
'visible' => (bool)sys_config('customer_service_switch', 1),
|
||
'order' => 128,
|
||
];
|
||
|
||
// 500. 插件自声明 section(extend_sections)
|
||
// 插件通过 OrderCenterDetailExtend 事件注入数据时同步声明渲染配置,
|
||
// 中台只透传不感知插件业务;props 可直接内嵌插件数据(数据同时保留在 extensions 中)
|
||
foreach ($extendSections as $sectionKey => $extendSection) {
|
||
if (!is_array($extendSection) || empty($extendSection)) {
|
||
continue;
|
||
}
|
||
$section = array_merge([
|
||
'component' => 'info_list',
|
||
'props' => ['fields' => $detail['extensions'][$sectionKey] ?? []],
|
||
'visible' => true,
|
||
'order' => 500,
|
||
], $extendSection);
|
||
// key 强制与声明键一致,保证 section 唯一性
|
||
$section['key'] = (string)$sectionKey;
|
||
$sections[] = $section;
|
||
}
|
||
|
||
// 按 order 升序排序(PHP 8 排序稳定,同 order 保持声明顺序)
|
||
usort($sections, fn($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0));
|
||
|
||
// 900. 时间线(移动端会被移除)
|
||
$sections[] = [
|
||
'key' => 'timeline',
|
||
'component' => 'timeline',
|
||
'props' => ['items' => $detail['timeline'] ?? []],
|
||
'visible' => true,
|
||
'order' => 900,
|
||
];
|
||
|
||
// 999. 底部操作按钮
|
||
$sections[] = [
|
||
'key' => 'buttons',
|
||
'component' => 'button_group',
|
||
'props' => ['buttons' => $detail['buttons'] ?? []],
|
||
'visible' => !empty($detail['buttons']),
|
||
'order' => 999,
|
||
];
|
||
|
||
return ['sections' => $sections];
|
||
}
|
||
|
||
/**
|
||
* 将扁平详情数据组装为分组结构(data 层)
|
||
* v8.1 全新设计:data 层只输出功能域分组,不保留扁平字段
|
||
* @param array $detail getDetail() 返回的扁平详情数据
|
||
* @return array 返回分组后的 data 结构
|
||
*/
|
||
protected function buildGroupedData(array $detail): array
|
||
{
|
||
// 能力解析必须在本方法内完成,局部变量不能跨方法共享。
|
||
$typeHandler = null;
|
||
$capabilities = [];
|
||
try {
|
||
$productType = (string)($detail['product_type'] ?? '');
|
||
if ($productType !== '') {
|
||
$typeHandler = app()->make(ProductTypeRegistry::class)->get($productType);
|
||
$capabilities = $typeHandler->getCapabilities();
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] resolve product capabilities failed: ' . $e->getMessage());
|
||
}
|
||
$isVirtual = (($capabilities['order']['virtual'] ?? false) === true);
|
||
// 状态映射图标(iconfont 项目 993865 中真实存在的图标,勿填不存在的字形名)
|
||
$statusIcons = [
|
||
'pending_payment' => 'icon-daifukuan',
|
||
'processing' => 'icon-daishouhuo',
|
||
'completed' => 'icon-yiwancheng',
|
||
'canceled' => 'icon-yiquxiao',
|
||
'after_sale' => 'icon-a-shouhoutuikuan',
|
||
'refunding' => 'icon-tuikuanzhong',
|
||
'refunded' => 'icon-yituikuan',
|
||
];
|
||
$statusCode = $detail['status_code'] ?? '';
|
||
$statusText = $detail['status_text'] ?? '';
|
||
$businessStatus = $detail['business_status'] ?? [];
|
||
|
||
// 状态对象
|
||
$status = [
|
||
'code' => $statusCode,
|
||
'text' => $statusText,
|
||
'icon' => $statusIcons[$statusCode] ?? 'icon-daishouhuo',
|
||
'subtitle' => $businessStatus['text'] ?? '',
|
||
'source_status' => $detail['source_status'] ?? '',
|
||
'business_status'=> $businessStatus,
|
||
'pay_status' => $detail['pay_status'] ?? 0,
|
||
'refund_status' => $detail['refund_status'] ?? 0,
|
||
'progress' => $detail['progress'] ?? ['current' => 0, 'status' => $statusCode, 'nodes' => []],
|
||
];
|
||
|
||
// 商品对象
|
||
$product = [
|
||
'title' => $detail['title'] ?? '',
|
||
'image' => $detail['image'] ?? '',
|
||
'subtitle' => $detail['subtitle'] ?? '',
|
||
'total_num' => $detail['total_num'] ?? 0,
|
||
'items' => $detail['order_items'] ?? [],
|
||
];
|
||
|
||
// 价格对象(含明细数组)
|
||
$pricingData = $this->buildPricingItems($detail);
|
||
$pricing = [
|
||
'items' => $pricingData['items'],
|
||
'pay_price' => $pricingData['pay_price'],
|
||
// 展示口径:商品总额按规格原价 × 数量汇总(原始 total_price 为优惠后金额,保留在详情顶层供业务计算)
|
||
'total_price' => $pricingData['original_total'] ?? ($detail['total_price'] ?? '0.00'),
|
||
'pay_postage' => $detail['pay_postage'] ?? '0.00',
|
||
'refund_price' => $detail['refund_price'] ?? '0.00',
|
||
'coupon_price' => $detail['coupon_price'] ?? '0.00',
|
||
'deduction_price' => $detail['deduction_price'] ?? '0.00',
|
||
'use_integral_num' => $detail['use_integral_num'] ?? 0,
|
||
'member_discount' => $detail['member_discount'] ?? '0.00',
|
||
'vip_discount' => $detail['vip_discount'] ?? '0.00',
|
||
'gift_price' => $detail['gift_price'] ?? '0.00',
|
||
];
|
||
|
||
// 订单信息对象
|
||
$orderInfo = [
|
||
'id' => $detail['id'] ?? 0,
|
||
'order_no' => $detail['order_no'] ?? '',
|
||
'uid' => $detail['uid'] ?? 0,
|
||
'order_category' => $detail['order_category'] ?? 'goods',
|
||
'product_type' => $detail['product_type'] ?? '',
|
||
'activity_type' => $detail['activity_type'] ?? '',
|
||
'activity_id' => $detail['activity_id'] ?? 0,
|
||
'source_type' => $detail['source_type'] ?? '',
|
||
'source_id' => $detail['source_id'] ?? 0,
|
||
'pay_type' => $detail['pay_type'] ?? '',
|
||
'pay_type_name' => $detail['pay_type_name'] ?? '',
|
||
'mark' => $detail['mark'] ?? '',
|
||
'remark' => $detail['remark'] ?? '',
|
||
'add_time' => $detail['add_time'] ?? 0,
|
||
'pay_time' => $detail['pay_time'] ?? 0,
|
||
'delivery_time' => $detail['delivery_time'] ?? 0,
|
||
'take_time' => $detail['take_time'] ?? 0,
|
||
'refund_time' => $detail['refund_time'] ?? 0,
|
||
'cancel_time' => $detail['cancel_time'] ?? 0,
|
||
'is_del' => $detail['is_del'] ?? 0,
|
||
'detail_route' => $detail['detail_route'] ?? '',
|
||
'admin_detail_url'=> $detail['admin_detail_url'] ?? '',
|
||
'activity_detail_url' => $detail['activity_detail_url'] ?? '',
|
||
// 送礼订单:1=购买礼物 0=普通单;gift_uid 非 0 表示已被领取
|
||
'is_gift' => $this->resolveIsGiftFlag($detail),
|
||
'gift_uid' => (int)($detail['gift_uid'] ?? $detail['extra_info']['gift']['gift_uid'] ?? ($detail['extra_info']['gift_uid'] ?? 0)),
|
||
'gift_mark' => (string)($detail['extra_info']['gift_mark'] ?? ''),
|
||
'gift_price' => $detail['gift_price'] ?? '0.00',
|
||
];
|
||
|
||
// 时间线
|
||
$timeline = $detail['timeline'] ?? [];
|
||
|
||
// 操作按钮
|
||
$buttons = $detail['order_buttons'] ?? [];
|
||
|
||
// 配送对象(仅实物商品)
|
||
$delivery = null;
|
||
if ($typeHandler && (($capabilities['delivery']['express'] ?? false)
|
||
|| ($capabilities['delivery']['pickup'] ?? false)
|
||
|| ($capabilities['delivery']['door_service'] ?? false))) {
|
||
$delivery = [
|
||
'shipping_type' => $detail['shipping_type'] ?? 1,
|
||
'is_pickup' => $detail['is_pickup'] ?? false,
|
||
'is_door_service' => $detail['is_door_service'] ?? false,
|
||
'store_id' => $detail['store_id'] ?? 0,
|
||
];
|
||
if (isset($detail['contact'])) {
|
||
$delivery['contact'] = $detail['contact'];
|
||
}
|
||
if (isset($detail['user_address'])) {
|
||
$delivery['user_address'] = $detail['user_address'];
|
||
}
|
||
if (isset($detail['store'])) {
|
||
$delivery['store'] = $detail['store'];
|
||
}
|
||
// 上门服务订单:预约信息(联系人/电话/地址/预约时间)
|
||
if (isset($detail['service'])) {
|
||
$delivery['service'] = $detail['service'];
|
||
}
|
||
}
|
||
|
||
// 核销对象(有核销码的订单:自提订单 或 已支付的非自提订单如酒店预订)
|
||
$verification = null;
|
||
if (!empty($detail['verify_code'])) {
|
||
$isPickupOrder = ($detail['is_pickup'] ?? false);
|
||
$isPaid = (int)($detail['pay_status'] ?? 0) === 1;
|
||
// 自提订单:无论支付状态都展示核销码;非自提订单(如酒店预订):仅已支付时展示
|
||
if ($isPickupOrder || $isPaid) {
|
||
$verification = [
|
||
'code' => $detail['verify_code'],
|
||
'qrcode_url' => '/api/qrcode?text=' . urlencode($detail['verify_code']),
|
||
'status' => $statusCode === 'completed' ? 'verified' : 'pending',
|
||
'verified_time' => $detail['take_time'] ?? 0,
|
||
];
|
||
}
|
||
}
|
||
|
||
// 倒计时(待支付订单)
|
||
$countdown = null;
|
||
if ($statusCode === 'pending_payment') {
|
||
$cancelHours = (int)sys_config('order_cancel_time', 0);
|
||
if ($cancelHours > 0) {
|
||
$endTime = (int)($detail['add_time'] ?? time()) + $cancelHours * 3600;
|
||
$remaining = max(0, $endTime - time());
|
||
$countdown = [
|
||
'end_time' => $endTime,
|
||
'remaining' => $remaining,
|
||
'text' => '请在 ' . gmdate('i:s', $remaining) . ' 内完成支付,超时订单将自动取消',
|
||
];
|
||
}
|
||
}
|
||
|
||
// 提示条
|
||
$notice = $this->buildNotice($statusCode, $detail);
|
||
|
||
// 关联订单
|
||
$relatedOrders = $this->buildRelatedOrders($detail);
|
||
|
||
// 支付详情(已支付订单才组装)
|
||
// trade_no/pay_method 优先从 extra_info 读取,缺失时留空
|
||
$payment = null;
|
||
if ((int)($detail['pay_status'] ?? 0) === 1) {
|
||
$extraInfo = $detail['extra_info'] ?? [];
|
||
$payment = [
|
||
'trade_no' => $extraInfo['trade_no'] ?? '',
|
||
'pay_time' => $detail['pay_time'] ?? 0,
|
||
'pay_method' => $extraInfo['pay_method'] ?? '',
|
||
'pay_price' => $detail['pay_price'] ?? '0.00',
|
||
];
|
||
}
|
||
|
||
// 扩展模块容器
|
||
$extensions = [];
|
||
|
||
// 自定义表单
|
||
if (!empty($detail['custom_form'])) {
|
||
$extensions['custom_form'] = $detail['custom_form'];
|
||
}
|
||
// 发票
|
||
if (!empty($detail['invoice'])) {
|
||
$extensions['invoice'] = $detail['invoice'];
|
||
}
|
||
// 上门服务订单:预约信息(供 service_appointment section 渲染)
|
||
if (!empty($detail['service'])) {
|
||
$extensions['service'] = $detail['service'];
|
||
}
|
||
// 事件注入的扩展数据(extend → extensions)
|
||
if (!empty($detail['extend'])) {
|
||
foreach ($detail['extend'] as $key => $value) {
|
||
$extensions[$key] = $value;
|
||
}
|
||
}
|
||
// 原始 extra_info(供深度定制用)
|
||
$extensions['extra_info'] = $detail['extra_info'] ?? [];
|
||
|
||
// 送礼订单信息:赠送人 / 领取人 / 留言 / 领取时间,供详情礼物卡片按查看者展示
|
||
$gift = null;
|
||
if ($this->resolveIsGiftFlag($detail) === 1) {
|
||
$extraInfo = $detail['extra_info'] ?? [];
|
||
$giftUid = (int)($detail['gift_uid'] ?? $extraInfo['gift']['gift_uid'] ?? ($extraInfo['gift_uid'] ?? 0));
|
||
$gift = [
|
||
'is_gift' => 1,
|
||
'gift_uid' => $giftUid,
|
||
'gift_mark' => (string)($extraInfo['gift_mark'] ?? ''),
|
||
'gift_time' => (int)($extraInfo['gift']['gift_time'] ?? 0),
|
||
'sender' => [
|
||
'uid' => (int)($detail['uid'] ?? 0),
|
||
'nickname' => (string)($detail['nickname'] ?? ''),
|
||
'avatar' => (string)($detail['user_avatar'] ?? ''),
|
||
],
|
||
'receiver' => $detail['gift_receiver'] ?? null,
|
||
];
|
||
}
|
||
|
||
// 返回分组结构:仅输出有值的分组,空分组(null/空数组)不下发,避免响应占位
|
||
$result = [
|
||
'status' => $status,
|
||
'product' => $product,
|
||
'pricing' => $pricing,
|
||
'order_info' => $orderInfo,
|
||
'extensions' => $extensions,
|
||
];
|
||
if ($gift !== null) {
|
||
$result['gift'] = $gift;
|
||
}
|
||
if (!empty($buttons)) {
|
||
$result['buttons'] = $buttons;
|
||
}
|
||
if (!empty($notice)) {
|
||
$result['notice'] = $notice;
|
||
}
|
||
if ($delivery !== null) {
|
||
$result['delivery'] = $delivery;
|
||
}
|
||
if (!empty($timeline)) {
|
||
$result['timeline'] = $timeline;
|
||
}
|
||
if ($countdown !== null) {
|
||
$result['countdown'] = $countdown;
|
||
}
|
||
if ($verification !== null) {
|
||
$result['verification'] = $verification;
|
||
}
|
||
if (!empty($relatedOrders)) {
|
||
$result['related_orders'] = $relatedOrders;
|
||
}
|
||
if ($payment !== null) {
|
||
$result['payment'] = $payment;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 从发票申请表加载订单发票信息
|
||
* 查询 eb_order_invoice 表,组装发票抬头和开票状态
|
||
* @param int $orderCenterId 订单中台ID(eb_order_center.id)
|
||
* @return array|null 发票信息数组,无申请记录返回 null
|
||
*/
|
||
protected function loadOrderInvoice(int $orderCenterId): ?array
|
||
{
|
||
try {
|
||
/** @var OrderInvoiceDao $invoiceDao */
|
||
$invoiceDao = app()->make(OrderInvoiceDao::class);
|
||
$invoice = $invoiceDao->getOne(['order_id' => $orderCenterId, 'is_del' => 0]);
|
||
if (!$invoice) {
|
||
return null;
|
||
}
|
||
$invoice = is_object($invoice) ? $invoice->toArray() : $invoice;
|
||
return [
|
||
'title' => $invoice['name'] ?? '', // 发票抬头名称
|
||
'header_type' => (int)($invoice['header_type'] ?? 1), // 1=个人 2=企业
|
||
'type' => (int)($invoice['type'] ?? 1), // 1=电子发票 2=纸质发票
|
||
'tax_number' => $invoice['duty_number'] ?? '', // 税号(企业抬头才有)
|
||
'email' => $invoice['email'] ?? '', // 接收邮箱
|
||
'phone' => $invoice['tell'] ?? '', // 联系电话
|
||
'address' => $invoice['address'] ?? '', // 地址
|
||
'bank' => $invoice['bank'] ?? '', // 开户银行
|
||
'bank_account' => $invoice['card_number'] ?? '', // 银行账号
|
||
'is_invoice' => (int)($invoice['is_invoice'] ?? 0), // 0=未开票 1=已开票
|
||
'invoice_number' => $invoice['invoice_number'] ?? '', // 发票号码(已开票时才有)
|
||
'invoice_time' => (int)($invoice['invoice_time'] ?? 0), // 开票时间
|
||
'is_refund' => (int)($invoice['is_refund'] ?? 0), // 0=正常 1=已红冲退款
|
||
];
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] 加载发票信息失败: ' . $e->getMessage());
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 规范化订单详情发票字段
|
||
* 兼容中台历史订单保存的 name/duty_number 字段,并统一类型,供各端详情页使用。
|
||
* @param mixed $invoice 发票信息
|
||
* @return array
|
||
*/
|
||
protected function normalizeInvoice($invoice): array
|
||
{
|
||
if (!is_array($invoice)) {
|
||
return [];
|
||
}
|
||
|
||
$title = trim((string)($invoice['title'] ?? ''));
|
||
if ($title === '') {
|
||
$title = trim((string)($invoice['name'] ?? ''));
|
||
}
|
||
$taxNumber = trim((string)($invoice['tax_number'] ?? ''));
|
||
if ($taxNumber === '') {
|
||
$taxNumber = trim((string)($invoice['duty_number'] ?? ''));
|
||
}
|
||
|
||
return array_merge($invoice, [
|
||
'title' => $title,
|
||
'header_type' => (int)($invoice['header_type'] ?? 1),
|
||
'type' => (int)($invoice['type'] ?? 1),
|
||
'tax_number' => $taxNumber,
|
||
'is_invoice' => (int)($invoice['is_invoice'] ?? 0),
|
||
'invoice_time' => (int)($invoice['invoice_time'] ?? 0),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 按订单状态生成运营提示条
|
||
* @param string $statusCode 订单状态码
|
||
* @param array $detail 订单详情
|
||
* @return array 返回提示条数组
|
||
*/
|
||
protected function buildNotice(string $statusCode, array $detail): array
|
||
{
|
||
$notices = [];
|
||
switch ($statusCode) {
|
||
case 'pending_payment':
|
||
$notices[] = ['type' => 'warning', 'text' => '请尽快完成支付,超时订单将自动取消'];
|
||
break;
|
||
case 'processing':
|
||
if (!empty($detail['delivery_time'])) {
|
||
$notices[] = ['type' => 'info', 'text' => '您的订单已发货,请留意收货'];
|
||
}
|
||
$notices[] = ['type' => 'info', 'text' => '签收后请在7天内确认收货'];
|
||
break;
|
||
case 'completed':
|
||
$notices[] = ['type' => 'info', 'text' => '订单已完成,感谢您的购买'];
|
||
break;
|
||
case 'after_sale':
|
||
case 'refunding':
|
||
$notices[] = ['type' => 'warning', 'text' => '退款处理中,请耐心等待'];
|
||
break;
|
||
case 'refunded':
|
||
$notices[] = ['type' => 'info', 'text' => '退款已完成'];
|
||
break;
|
||
case 'canceled':
|
||
$notices[] = ['type' => 'info', 'text' => '订单已取消'];
|
||
break;
|
||
}
|
||
return $notices;
|
||
}
|
||
|
||
/**
|
||
* 查询关联订单(换货等衍生单)
|
||
* 系统不支持拆单发货/拆单退款,一个订单不会派生子订单,当前恒为 null
|
||
* @param array $detail 订单详情
|
||
* @return array|null 返回关联订单数组或null
|
||
*/
|
||
protected function buildRelatedOrders(array $detail): ?array
|
||
{
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 获取带声明式渲染配置的订单详情
|
||
* v8.1 最终形态:接口只返回 page_config,不再返回 data 分组
|
||
* - page_config.sections:声明式渲染清单,props 直填渲染值(前端零解析)
|
||
* 由中台基础 section + 插件 extend_sections 自声明 section 组成
|
||
* - page_config.extensions:扩展数据(含 extra_info 深度定制数据)
|
||
* @param int $id 订单中台记录ID
|
||
* @param bool $withRemoteLogistics 是否同步请求第三方物流轨迹(首屏可关闭,由前端异步补齐)
|
||
* @return array 返回 {page_config: {sections: [...], extensions: {...}}}
|
||
*/
|
||
public function getDetailWithConfig(int $id, bool $withRemoteLogistics = true): array
|
||
{
|
||
// 1. 获取扁平详情数据(复用现有 getDetail 逻辑)
|
||
$flatDetail = $this->getDetail($id, $withRemoteLogistics);
|
||
|
||
// 2. 组装分组数据(内部使用:buildDefaultPageConfig 依赖全量分组生成 sections)
|
||
$data = $this->buildGroupedData($flatDetail);
|
||
|
||
// 3. 组装 page_config:中台基础 section + 插件自声明 section(extend_sections)
|
||
$pageConfig = $this->buildDefaultPageConfig($data, $flatDetail['extend_sections'] ?? []);
|
||
|
||
// 4. 只下发可见 section(visible=true)
|
||
// 业态通用配置(隐藏项)不随接口下发,前端无需再过滤,显著减小响应体积
|
||
$pageConfig['sections'] = array_values(array_filter($pageConfig['sections'] ?? [], function ($s) {
|
||
return ($s['visible'] ?? false) === true;
|
||
}));
|
||
|
||
// 5. 最终返回:page_config = {sections, extensions}
|
||
// extensions 含 extra_info(深度定制数据源)及事件注入的扩展数据
|
||
$result = [
|
||
'page_config' => [
|
||
'sections' => $pageConfig['sections'],
|
||
'extensions' => $data['extensions'] ?? [],
|
||
],
|
||
];
|
||
|
||
// 透传扩展模块注入的 redirect 指令(如拼团未成团时跳转拼团进度页)
|
||
if (!empty($flatDetail['redirect'])) {
|
||
$result['redirect'] = $flatDetail['redirect'];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 根据订单号获取带声明式渲染配置的订单详情
|
||
* @param string $orderNo 订单号
|
||
* @param bool $withRemoteLogistics 是否同步请求第三方物流轨迹
|
||
* @return array 返回 {page_config, data}
|
||
* @throws \RuntimeException 订单不存在时抛出异常
|
||
*/
|
||
public function getDetailWithConfigByOrderNo(string $orderNo, bool $withRemoteLogistics = true): array
|
||
{
|
||
$detail = $this->dao->get(['order_no' => $orderNo], ['id']);
|
||
if (!$detail) {
|
||
throw new \RuntimeException('订单不存在');
|
||
}
|
||
return $this->getDetailWithConfig((int)$detail['id'], $withRemoteLogistics);
|
||
}
|
||
|
||
/**
|
||
* 加载商品明细
|
||
* 统一从 extra_info.items 快照读取(旧 eb_store_order_cart_info 表已删除)
|
||
* @param array $detail 订单详情数据
|
||
* @return array 返回商品明细列表
|
||
*/
|
||
public function loadOrderItems(array $detail): array
|
||
{
|
||
if (!empty($detail['extra_info']['items'])) {
|
||
return $detail['extra_info']['items'];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* 获取订单商品快照列表(中台化读取基础方法)
|
||
* 支持中台主键或订单号两种标识符,优先按 order_no 匹配(订单号含字母前缀,不与自增主键冲突)
|
||
* @param int|string $id 中台订单主键或订单号
|
||
* @return array extra_info.items 商品快照列表
|
||
*/
|
||
protected function getOrderCartItems($id): array
|
||
{
|
||
if (empty($id)) return [];
|
||
$order = $this->dao->get(['order_no' => (string)$id]);
|
||
if (!$order) {
|
||
$order = $this->dao->get(['id' => (int)$id]);
|
||
}
|
||
if (!$order) return [];
|
||
$detail = $order->toArray();
|
||
$detail['extra_info'] = $this->parseExtraInfo($detail['extra_info'] ?? null);
|
||
return $this->loadOrderItems($detail);
|
||
}
|
||
|
||
/**
|
||
* 获取订单商品详情(中台化读取)
|
||
* 基于 extra_info.items 快照构建,兼容旧订单购物车明细 getOrderCartInfo 返回结构
|
||
* [cart_id => ['cart_info' => [...]]],缺失字段(costPrice/price_type 等)由调用方兜底
|
||
* @param int|string $id 中台订单主键或订单号
|
||
* @return array
|
||
*/
|
||
public function getOrderCartInfo($id): array
|
||
{
|
||
$info = [];
|
||
foreach ($this->getOrderCartItems($id) as $item) {
|
||
$productInfo = $item['attr_info'] ?? [];
|
||
$productInfo['store_name'] = $item['store_name'] ?? '';
|
||
$productInfo['image'] = $item['image'] ?? '';
|
||
$productInfo['price'] = $item['price'] ?? '0.00';
|
||
$info[$item['cart_id'] ?? 0] = [
|
||
'cart_info' => [
|
||
'id' => $item['cart_id'] ?? 0,
|
||
'product_id' => $item['product_id'] ?? 0,
|
||
'cart_num' => $item['cart_num'] ?? 1,
|
||
'truePrice' => $item['true_price'] ?? ($item['price'] ?? '0.00'),
|
||
'productInfo' => get_thumb_water($productInfo),
|
||
],
|
||
];
|
||
}
|
||
return $info;
|
||
}
|
||
|
||
/**
|
||
* 获取订单商品标题(中台化读取)
|
||
* 基于 extra_info.items 拼接商品名称,兼容旧 getCarIdByProductTitle 输出格式
|
||
* @param int|string $id 中台订单主键或订单号
|
||
* @param bool $goodsNum 是否附带购买数量
|
||
* @return string
|
||
*/
|
||
public function getCarIdByProductTitle($id, bool $goodsNum = false): string
|
||
{
|
||
$title = '';
|
||
foreach ($this->getOrderCartItems($id) as $item) {
|
||
if (empty($item['store_name'])) continue;
|
||
if ($goodsNum) {
|
||
$title .= $item['store_name'] . ' * ' . ($item['cart_num'] ?? 1) . ' | ';
|
||
} else {
|
||
$title .= $item['store_name'] . '|';
|
||
}
|
||
}
|
||
return $title ? substr($title, 0, strlen($title) - 1) : '';
|
||
}
|
||
|
||
/**
|
||
* 获取订单商品ID列表(中台化读取)
|
||
* 基于 extra_info.items 提取 product_id,替代旧 getColumn(['oid'=>...], 'product_id')
|
||
* @param int|string $id 中台订单主键或订单号
|
||
* @return array
|
||
*/
|
||
public function getCartProductIds($id): array
|
||
{
|
||
return array_values(array_unique(array_filter(array_column($this->getOrderCartItems($id), 'product_id'))));
|
||
}
|
||
|
||
/**
|
||
* 从扩展订单表加载补充信息并重新推导状态码
|
||
* 通过状态注册器从扩展订单表实时读取数据,确保订单状态显示正确
|
||
* @param array $detail 订单详情数据(引用传递)
|
||
*/
|
||
protected function loadSourceOrderInfo(array &$detail): void
|
||
{
|
||
$sourceType = $detail['source_type'] ?? '';
|
||
$sourceId = $detail['source_id'] ?? 0;
|
||
|
||
if (empty($sourceType) || $sourceId <= 0) {
|
||
return;
|
||
}
|
||
|
||
$adapter = $this->resolveOrderAdapter($sourceType);
|
||
if (!$adapter) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$sourceOrder = $this->getSourceOrderData($sourceType, $sourceId);
|
||
|
||
if ($sourceOrder) {
|
||
$centerStatus = (string)($detail['status_code'] ?? '');
|
||
$sourceStatus = $adapter->resolveStatusCode($sourceOrder);
|
||
if (!$this->isAuthoritativeCenterStatus($centerStatus) || $sourceStatus === $centerStatus) {
|
||
$detail['status_code'] = $sourceStatus;
|
||
$detail['business_status'] = $adapter->resolveBusinessStatus($sourceOrder);
|
||
}
|
||
// 将扩展表状态透传到详情,供按钮、进度条按子状态差异化处理
|
||
$detail['source_status'] = $sourceOrder['status'] ?? '';
|
||
|
||
// 配送方式/核销码已归属中台表实体列,直接从 $detail 读取
|
||
// 扩展表不再需要回退这些字段
|
||
|
||
// 从扩展表提取物流配送信息(delivery_name/delivery_id/delivery_type)
|
||
// 这些字段仅存在于扩展表 eb_pt_normal_order,中台表无对应实体列
|
||
if (!empty($sourceOrder['delivery_name'])) {
|
||
$detail['delivery_name'] = $sourceOrder['delivery_name'];
|
||
}
|
||
if (!empty($sourceOrder['delivery_id'])) {
|
||
$detail['delivery_id'] = $sourceOrder['delivery_id'];
|
||
}
|
||
if (!empty($sourceOrder['delivery_type'])) {
|
||
$detail['delivery_type'] = $sourceOrder['delivery_type'];
|
||
}
|
||
|
||
// 同步出票/核销时间到详情(中台表无 send_time 列,供门票进度条"已出票"节点取时间)
|
||
if (!empty($sourceOrder['send_time'])) {
|
||
$detail['send_time'] = (int)$sourceOrder['send_time'];
|
||
}
|
||
|
||
// 旧订单的收货地址存在 extension 表(real_name/user_phone/user_address),
|
||
// 新订单已写入 extra_info.contact;如果 extra_info 中无 contact,从 source 回退
|
||
if (!isset($detail['extra_info']['contact']) && !empty($sourceOrder['real_name'])) {
|
||
$detail['contact'] = [
|
||
'name' => $sourceOrder['real_name'],
|
||
'phone' => $sourceOrder['user_phone'] ?? '',
|
||
'address' => $sourceOrder['user_address'] ?? '',
|
||
];
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] loadSourceOrderInfo failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
protected function isAuthoritativeCenterStatus(string $statusCode): bool
|
||
{
|
||
return in_array($statusCode, [
|
||
OrderCenter::STATUS_COMPLETED,
|
||
OrderCenter::STATUS_CANCELED,
|
||
OrderCenter::STATUS_AFTER_SALE,
|
||
OrderCenter::STATUS_REFUNDING,
|
||
OrderCenter::STATUS_REFUNDED,
|
||
OrderCenter::STATUS_DELETED,
|
||
], true);
|
||
}
|
||
|
||
/**
|
||
* 加载售后进度扩展数据
|
||
* 查询订单最新售后单,构建含 type_text/status_text/timeline 的摘要,注入 extend.after_sale
|
||
* 前端 AfterSaleProgress 组件据此渲染,无需硬编码售后类型映射表
|
||
* @param array $detail 订单详情数据(引用传递)
|
||
* @return void
|
||
*/
|
||
protected function loadAfterSaleExtension(array &$detail): void
|
||
{
|
||
$orderNo = $detail['order_no'] ?? '';
|
||
if (empty($orderNo)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 查询订单最新售后单号
|
||
/** @var AfterSaleDao $afterSaleDao */
|
||
$afterSaleDao = $this->app->make(AfterSaleDao::class);
|
||
$afterSaleNo = $afterSaleDao->getLatestNoByOrderNo($orderNo);
|
||
if (empty($afterSaleNo)) {
|
||
return;
|
||
}
|
||
|
||
// 调用售后服务获取完整详情(含 type_text/status_text/timeline)
|
||
/** @var AfterSaleServices $afterSaleServices */
|
||
$afterSaleServices = $this->app->make(AfterSaleServices::class);
|
||
$afterSaleDetail = $afterSaleServices->getDetail($afterSaleNo);
|
||
if (empty($afterSaleDetail)) {
|
||
return;
|
||
}
|
||
|
||
// timeline 格式转换:后端 {key,label,time,done} → 前端 {title,time,desc}
|
||
// 仅保留已发生的节点(time > 0),过滤掉未发生的"待处理"节点
|
||
// 订单记录时间轴只展示已发生的事件,避免未发生节点污染时间顺序
|
||
$rawTimeline = $afterSaleDetail['timeline'] ?? [];
|
||
$timeline = [];
|
||
foreach ($rawTimeline as $node) {
|
||
$ts = (int)($node['time'] ?? 0);
|
||
// 时间戳为 0 表示该节点尚未发生,不纳入订单记录时间轴
|
||
if ($ts <= 0) {
|
||
continue;
|
||
}
|
||
$timeline[] = [
|
||
'title' => $node['label'] ?? '',
|
||
'time' => date('Y-m-d H:i', $ts),
|
||
'desc' => '',
|
||
];
|
||
}
|
||
|
||
// 仅提取详情页展示所需字段,避免下发冗余数据
|
||
$detail['extend']['after_sale'] = [
|
||
'after_sale_no' => $afterSaleDetail['after_sale_no'] ?? '',
|
||
'type' => $afterSaleDetail['type'] ?? '',
|
||
'type_text' => $afterSaleDetail['type_text'] ?? '',
|
||
'status' => $afterSaleDetail['status'] ?? '',
|
||
'status_text' => $afterSaleDetail['status_text'] ?? '',
|
||
'amount' => $afterSaleDetail['refund_price'] ?? 0,
|
||
'reason' => $afterSaleDetail['reason'] ?? '',
|
||
'timeline' => $timeline,
|
||
];
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] loadAfterSaleExtension failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载物流轨迹扩展数据
|
||
* 已发货且填了快递单号的订单,读取物流轨迹(默认允许同步查询第三方;详情首屏可只读缓存)并注入 extend.logistics
|
||
* 前端 LogisticsTimeline 组件据此渲染物流轨迹卡片,用户无需点"查看物流"单独查询
|
||
* 数据流:本方法写入 $detail['extend']['logistics']
|
||
* → buildGroupedData() 合并到 extensions['logistics']
|
||
* → page_config 中 component='timeline' + key='logistics' → 复合 key 'timeline:logistics'
|
||
* → 前端 LogisticsTimeline 渲染 delivery_name/delivery_id + OrderTimeline 遍历 tracks
|
||
* @param array $detail 订单详情数据(引用传递)
|
||
* @param bool $allowRemote 是否允许同步请求第三方物流接口;false 时仅读取已有缓存,避免详情首屏被慢请求阻塞
|
||
* @return void
|
||
*/
|
||
protected function loadLogisticsExtension(array &$detail, bool $allowRemote = true): void
|
||
{
|
||
// 仅处理有快递单号的订单(已发货且填写了物流单号)
|
||
$deliveryId = (string)($detail['delivery_id'] ?? '');
|
||
if ($deliveryId === '') {
|
||
return;
|
||
}
|
||
|
||
// 到店自提 / 上门服务订单无物流轨迹,仅处理快递配送
|
||
$deliveryType = (string)($detail['delivery_type'] ?? '');
|
||
if ($deliveryType !== '' && $deliveryType !== 'express') {
|
||
return;
|
||
}
|
||
|
||
$orderNo = (string)($detail['order_no'] ?? '');
|
||
$deliveryName = (string)($detail['delivery_name'] ?? '');
|
||
// delivery_code 传空字符串而非 null:一号通驱动 query() 第二参数声明为 string,null 会触发 TypeError
|
||
$deliveryCode = (string)($detail['delivery_code'] ?? '');
|
||
|
||
// 物流轨迹查询(失败时 tracks 置空,仍展示快递公司 + 单号,不阻断详情渲染)
|
||
$tracks = [];
|
||
try {
|
||
// 收件人手机号(顺丰等物流查询需要)
|
||
$phone = '';
|
||
$contact = $detail['contact'] ?? ($detail['extra_info']['contact'] ?? []);
|
||
if (is_array($contact) && !empty($contact['phone'])) {
|
||
$phone = (string)$contact['phone'];
|
||
}
|
||
|
||
// 缓存键名:订单号 + 快递单号(ExpressServices 内部按此缓存,已签收缓存 0 秒,未签收缓存 1800 秒)
|
||
$cacheName = $orderNo . $deliveryId;
|
||
|
||
/** @var ExpressServices $expressServices */
|
||
$expressServices = $this->app->make(ExpressServices::class);
|
||
// 详情首屏只读缓存;轨迹按需由前端调用 order/express 异步补齐,避免第三方慢请求阻塞详情
|
||
$rawTracks = $allowRemote
|
||
? $expressServices->query($cacheName, $deliveryId, $deliveryCode, $phone)
|
||
: $expressServices->getCachedTracks($cacheName);
|
||
|
||
// 归一化轨迹字段为前端统一格式 {title, time, desc}
|
||
// 驱动返回格式差异:一号通 {time,status} / 阿里云 {time,context} / 其他兼容
|
||
foreach ($rawTracks ?? [] as $track) {
|
||
if (!is_array($track)) {
|
||
continue;
|
||
}
|
||
$tracks[] = [
|
||
'title' => (string)($track['title'] ?? $track['status'] ?? $track['context'] ?? $track['name'] ?? ''),
|
||
'time' => (string)($track['time'] ?? $track['ftime'] ?? ''),
|
||
'desc' => (string)($track['desc'] ?? ''),
|
||
];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 物流查询失败不阻断订单详情渲染(驱动未配置 / 网络异常等场景降级为仅展示快递公司+单号)
|
||
Log::warning('[OrderCenter] loadLogisticsExtension failed: ' . $e->getMessage());
|
||
}
|
||
|
||
// 始终写入 extend.logistics:即使 tracks 查询失败,也展示快递公司 + 单号
|
||
$detail['extend']['logistics'] = [
|
||
'delivery_name' => $deliveryName,
|
||
'delivery_id' => $deliveryId,
|
||
'tracks' => $tracks,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 加载订单完成态权益扩展数据(系统内置能力,非插件扩展)
|
||
* 积分奖励:聚合本单积分入账流水(收货时由 OrderTakeServices 发放),有入账才展示;
|
||
* 领券入口:订单完成后展示领券中心入口卡片
|
||
* 前端 ActionCard 引擎对带 link 的入口统一跳转,无需前端硬编码 action 处理
|
||
* @param array $detail 订单详情数据(引用传递)
|
||
* @return void
|
||
*/
|
||
protected function loadOrderRewardExtension(array &$detail): void
|
||
{
|
||
// 仅已完成订单展示完成态权益
|
||
if (((string)($detail['status_code'] ?? '')) !== OrderCenter::STATUS_COMPLETED) {
|
||
return;
|
||
}
|
||
|
||
// 领券入口:固定卡片,跳转领券中心
|
||
$detail['extend']['coupon_gift'] = [
|
||
'title' => '领取优惠券',
|
||
'link' => '/pages/users/user_get_coupon/index',
|
||
];
|
||
|
||
// 积分奖励:查询本单积分入账流水,无入账不展示
|
||
$uid = (int)($detail['uid'] ?? 0);
|
||
$orderId = (int)($detail['id'] ?? 0);
|
||
if ($uid <= 0 || $orderId <= 0) {
|
||
return;
|
||
}
|
||
try {
|
||
/** @var UserBillDao $userBillDao */
|
||
$userBillDao = $this->app->make(UserBillDao::class);
|
||
$integral = $userBillDao->getOrderGainIntegralSum($uid, $orderId);
|
||
if ($integral > 0) {
|
||
$detail['extend']['points_reward'] = [
|
||
'text' => '本单奖励 ' . (int)$integral . ' 积分',
|
||
'link' => '/pages/users/user_integral/index',
|
||
];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] loadOrderRewardExtension failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据源类型获取扩展订单表数据
|
||
* @param string $sourceType 源类型标识
|
||
* @param int $sourceId 源订单ID
|
||
* @return array|null 返回扩展订单数据或null
|
||
*/
|
||
protected function getSourceOrderData(string $sourceType, int $sourceId): ?array
|
||
{
|
||
$adapter = $this->resolveOrderAdapter($sourceType);
|
||
if (!$adapter) {
|
||
return null;
|
||
}
|
||
|
||
/** @var \crmeb\basic\BaseModel $model */
|
||
$model = $this->app->make($adapter::getModelClass());
|
||
$sourceOrder = $model->where('id', $sourceId)->find();
|
||
|
||
return $sourceOrder ? $sourceOrder->toArray() : null;
|
||
}
|
||
|
||
/**
|
||
* 将自定义表单数据从对象格式转换为数组格式
|
||
* 兼容两种格式:对象格式 {"custom_field_0": "value"} 和数组格式 [{"label": "..."}]
|
||
* @param mixed $customForm 自定义表单数据
|
||
* @return array 返回数组格式的自定义表单数据
|
||
*/
|
||
protected function transformCustomForm($customForm): array
|
||
{
|
||
if (!is_array($customForm)) {
|
||
return [];
|
||
}
|
||
|
||
if (isset($customForm[0]) && is_array($customForm[0])) {
|
||
return $customForm;
|
||
}
|
||
|
||
$result = [];
|
||
foreach ($customForm as $key => $value) {
|
||
$label = $key;
|
||
$title = $key;
|
||
|
||
if (strpos($key, 'custom_field_') === 0) {
|
||
$index = (int) str_replace('custom_field_', '', $key);
|
||
$title = '自定义字段' . ($index + 1);
|
||
}
|
||
|
||
$result[] = [
|
||
'label' => $label,
|
||
'title' => $title,
|
||
'value' => $value,
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 构建订单时间线
|
||
* 从 order_log 订单状态日志表读取,按实际发生的每一步状态变更生成时间线
|
||
* @param array $detail 订单详情数据
|
||
* @return array 返回时间线数组(最新事件在前)
|
||
*/
|
||
protected function buildTimeline(array $detail): array
|
||
{
|
||
$timeline = [];
|
||
|
||
// 从订单号读取订单状态日志
|
||
$orderNo = (string)($detail['order_no'] ?? '');
|
||
if ($orderNo === '') {
|
||
return $timeline;
|
||
}
|
||
|
||
try {
|
||
/** @var \app\dao\order\OrderLogDao $logDao */
|
||
$logDao = app()->make(\app\dao\order\OrderLogDao::class);
|
||
$records = $logDao->getLogList(['order_no' => $orderNo]);
|
||
|
||
foreach ($records as $record) {
|
||
$fromStatus = $record['from_status'] ?? '';
|
||
$toStatus = $record['to_status'] ?? '';
|
||
$content = $record['content'] ?? '';
|
||
$addTime = (int)($record['add_time'] ?? 0);
|
||
|
||
if ($addTime <= 0) {
|
||
continue;
|
||
}
|
||
|
||
// 状态未变化时(如发货、改价、删除),使用content作为标题
|
||
// 状态变化时,使用状态码映射标题
|
||
$title = ($fromStatus === $toStatus && $fromStatus !== '')
|
||
? $content
|
||
: $this->mapStatusToTitle($toStatus, $content);
|
||
|
||
$timeline[] = [
|
||
'title' => $title,
|
||
'desc' => $content,
|
||
'time' => date('Y-m-d H:i:s', $addTime),
|
||
];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] buildTimeline failed: ' . $e->getMessage());
|
||
}
|
||
|
||
// 时间线倒序排列,最新事件在前
|
||
return array_reverse($timeline);
|
||
}
|
||
|
||
/**
|
||
* 将订单状态码映射为时间线标题
|
||
* @param string $toStatus 变更后状态码
|
||
* @param string $content 操作说明(作为回退标题)
|
||
* @return string 返回标题
|
||
*/
|
||
protected function mapStatusToTitle(string $toStatus, string $content): string
|
||
{
|
||
$statusMap = [
|
||
OrderCenter::STATUS_PENDING_PAYMENT => '提交订单',
|
||
OrderCenter::STATUS_PROCESSING => '订单处理中',
|
||
OrderCenter::STATUS_PENDING_REVIEW => '待评价',
|
||
OrderCenter::STATUS_COMPLETED => '订单完成',
|
||
OrderCenter::STATUS_CANCELED => '订单取消',
|
||
OrderCenter::STATUS_AFTER_SALE => '售后处理中',
|
||
OrderCenter::STATUS_REFUNDING => '售后处理中',
|
||
OrderCenter::STATUS_REFUNDED => '退款完成',
|
||
];
|
||
|
||
return $statusMap[$toStatus] ?? $content;
|
||
}
|
||
|
||
/**
|
||
* 获取订单操作按钮
|
||
* 委托给订单类型适配器(OrderTypeRegistry),由各产品类型根据自身流程决定按钮
|
||
* 注:调用 getOrderButtons 而非 getStatusButtons,由基类统一注入 order_no 到 params;
|
||
* 同时为 viewAfterSale 按钮额外注入 after_sale_no(按订单号查最新售后单),避免前端跳转售后详情时缺参
|
||
* @param array $detail 订单详情数据
|
||
* @return array 返回按钮数组
|
||
*/
|
||
protected function getOrderButtonsFromHandler(array $detail): array
|
||
{
|
||
$adapter = $this->resolveOrderAdapter((string)($detail['source_type'] ?? ''));
|
||
if (!$adapter) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
// 调用 getOrderButtons(基类已统一注入 order_no 到每个按钮的 params)
|
||
$buttons = $adapter->getOrderButtons($detail);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('getOrderButtonsFromHandler failed: ' . $e->getMessage());
|
||
// 回退:返回空数组
|
||
return [];
|
||
}
|
||
|
||
// 为 viewAfterSale 按钮补充 after_sale_no(按订单号查最新售后单)
|
||
// 前端 viewAfterSale action 依赖 params.after_sale_no 才能跳转到售后详情页
|
||
$orderNo = $detail['order_no'] ?? '';
|
||
if ($orderNo) {
|
||
foreach ($buttons as &$button) {
|
||
if (($button['action'] ?? '') === 'viewAfterSale') {
|
||
$afterSaleNo = $this->resolveLatestAfterSaleNo($orderNo);
|
||
if ($afterSaleNo !== '') {
|
||
$button['params'] = array_merge($button['params'] ?? [], ['after_sale_no' => $afterSaleNo]);
|
||
}
|
||
}
|
||
}
|
||
unset($button);
|
||
}
|
||
|
||
return $buttons;
|
||
}
|
||
|
||
/**
|
||
* 查询订单最新售后单号
|
||
* 用于 viewAfterSale 按钮参数注入,取订单最近一条售后单(不限状态,含已取消/已完成)
|
||
* @param string $orderNo 订单号
|
||
* @return string 返回售后单号,无售后记录返回空字符串
|
||
*/
|
||
protected function resolveLatestAfterSaleNo(string $orderNo): string
|
||
{
|
||
try {
|
||
/** @var AfterSaleDao $afterSaleDao */
|
||
$afterSaleDao = $this->app->make(AfterSaleDao::class);
|
||
return $afterSaleDao->getLatestNoByOrderNo($orderNo);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] resolveLatestAfterSaleNo failed: ' . $e->getMessage());
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取订单生命周期进度条
|
||
* 委托给订单类型适配器(OrderTypeRegistry),不同订单类型节点不同
|
||
* @param array $detail 订单详情数据
|
||
* @return array 返回进度条结构
|
||
*/
|
||
protected function getProgressFromHandler(array $detail): array
|
||
{
|
||
$adapter = $this->resolveOrderAdapter((string)($detail['source_type'] ?? ''));
|
||
if (!$adapter) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
return $adapter->getProgress($detail);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('getProgressFromHandler failed: ' . $e->getMessage());
|
||
}
|
||
return [];
|
||
}
|
||
|
||
|
||
/**
|
||
* 根据产品类型生成后台详情路由
|
||
* 按约定生成 /admin/product/{product_type}/order/detail/{order_no},
|
||
* 新增产品类型只要遵循该约定即自动生效,无需修改中台代码
|
||
* @param array $detail 订单详情数据
|
||
* @return string 返回相对路径路由
|
||
*/
|
||
protected function getAdminDetailUrl(array $detail): string
|
||
{
|
||
$type = (string)($detail['product_type'] ?? '');
|
||
$orderNo = (string)($detail['order_no'] ?? '');
|
||
if ($type === '' || $orderNo === '') {
|
||
return '';
|
||
}
|
||
|
||
return '/admin/product/' . $type . '/order/detail/' . $orderNo;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 使用雪花算法生成业务唯一单号
|
||
* 统一收口订单号生成,便于活动、充值、抽奖等非订单业务复用
|
||
* @param string $prefix 单号前缀,如 wx、cz、hb
|
||
* @return string 返回带前缀的唯一单号
|
||
* @throws \Exception
|
||
*/
|
||
public function getNewOrderId(string $prefix = 'wx'): string
|
||
{
|
||
return SnowflakeIdGenerator::generate($prefix);
|
||
}
|
||
|
||
/**
|
||
* 生成提货核销码(12 位数字,中台表查重保证唯一)
|
||
* 核销码生成(中台统一收口)
|
||
* @return string
|
||
*/
|
||
public function getStoreCode(): string
|
||
{
|
||
// 时间戳 + 随机数 + 微秒片段拼接生成 12 位数字串
|
||
list($msec, $sec) = explode(' ', microtime());
|
||
$num = time() + mt_rand(10, 999999) . '' . substr($msec, 2, 3);
|
||
if (strlen($num) < 12) {
|
||
$num = str_pad((string)$num, 12, '0', STR_PAD_RIGHT);
|
||
} else {
|
||
$num = substr((string)$num, 0, 12);
|
||
}
|
||
// 与中台已有核销码查重,冲突则递归重生成
|
||
if ($this->dao->count(['verify_code' => $num])) {
|
||
return $this->getStoreCode();
|
||
}
|
||
return $num;
|
||
}
|
||
|
||
/**
|
||
* 订单自动好评(定时任务)
|
||
* 收货后超过 system_comment_time 天未评价的订单,按商品维度自动生成五星好评,
|
||
* 全部商品评价完成后订单由待评价流转为已完成
|
||
* @return void
|
||
*/
|
||
public function autoComment(): void
|
||
{
|
||
// 自动评价天数(0 或未配置表示关闭)
|
||
$days = (int)sys_config('system_comment_time', 0);
|
||
if ($days <= 0) {
|
||
return;
|
||
}
|
||
$deadline = time() - $days * 86400;
|
||
|
||
// 待评价订单:待评价状态 + 收货时间超过自动评价期限
|
||
$orderList = $this->dao->getDb()
|
||
->where('status_code', OrderCenter::STATUS_PENDING_REVIEW)
|
||
->where('is_del', 0)
|
||
->where('take_time', '>', 0)
|
||
->where('take_time', '<=', $deadline)
|
||
->field('id,order_no,source_type,uid,extra_info,take_time')
|
||
->select()->toArray();
|
||
if (empty($orderList)) {
|
||
return;
|
||
}
|
||
|
||
/** @var StoreProductReplyServices $replyServices */
|
||
$replyServices = app()->make(StoreProductReplyServices::class);
|
||
// 自动好评默认文案(后台可配)
|
||
$defaultComment = sys_config('comment_content', '此用户未做评价');
|
||
|
||
foreach ($orderList as $order) {
|
||
// extra_info 可能被模型 JSON 类型自动转为数组,兼容两种形态
|
||
$extra = $order['extra_info'] ?? [];
|
||
if (is_string($extra)) {
|
||
$extra = json_decode($extra, true) ?: [];
|
||
}
|
||
$items = $extra['items'] ?? [];
|
||
// 无商品快照的订单直接完成状态流转,避免卡在待评价
|
||
if (empty($items)) {
|
||
$this->completeOrderStatus($order);
|
||
continue;
|
||
}
|
||
// 用户信息(评价展示昵称/头像)
|
||
$user = User::where('uid', $order['uid'])->field('uid,nickname,avatar')->find();
|
||
$allReplied = true;
|
||
foreach ($items as $item) {
|
||
$unique = $item['attr_info']['unique'] ?? '';
|
||
$productId = (int)($item['product_id'] ?? 0);
|
||
if (!$productId || !$unique) {
|
||
continue;
|
||
}
|
||
// 防重:同订单同 SKU 只评价一次(oid 为中台订单 id,unique 为 SKU 快照唯一标识)
|
||
if ($replyServices->count(['oid' => $order['id'], 'unique' => $unique]) > 0) {
|
||
continue;
|
||
}
|
||
$data = [
|
||
'uid' => $order['uid'],
|
||
'nickname' => $user['nickname'] ?? '',
|
||
'avatar' => $user['avatar'] ?? '',
|
||
'oid' => $order['id'],
|
||
'product_id' => $productId,
|
||
'unique' => $unique,
|
||
'reply_type' => 'product',
|
||
'product_score' => 5,
|
||
'service_score' => 5,
|
||
'comment' => $defaultComment,
|
||
'pics' => '[]',
|
||
'suk' => $item['attr_info']['suk'] ?? '',
|
||
'add_time' => time(),
|
||
'status' => 1,
|
||
];
|
||
if (!$replyServices->save($data)) {
|
||
$allReplied = false;
|
||
continue;
|
||
}
|
||
// 商品评价事件(商品评分统计等扩展点)
|
||
event('Product', ['reply', [
|
||
'uid' => $order['uid'],
|
||
'product_id' => $productId,
|
||
'order_id' => $order['id'],
|
||
'star' => 5,
|
||
'content' => $defaultComment,
|
||
]]);
|
||
}
|
||
// 全部商品评价完成:待评价 → 已完成
|
||
if ($allReplied) {
|
||
$this->completeOrderStatus($order);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 扫描超时未支付订单并入队提醒任务
|
||
* 定时任务 noticeUnpaidOrder 调用:与下单后 10 分钟的队列提醒(UnpaidOrderSend::dispatchSecs)互补,
|
||
* 本扫描只处理下单超过 20 分钟、尚未到自动取消期限的待支付订单,配合缓存防重避免重复打扰
|
||
* @return int 本次入队提醒的订单数
|
||
*/
|
||
public function scanUnpaidOrders(): int
|
||
{
|
||
// 自动取消期限(小时),0 或未配置按 24 小时兜底,避免扫描窗口无界
|
||
$cancelHours = (int)sys_config('order_cancel_time', 0);
|
||
if ($cancelHours <= 0) {
|
||
$cancelHours = 24;
|
||
}
|
||
$now = time();
|
||
// 20 分钟前下单的才扫,避开下单后 10 分钟的队列提醒时间点
|
||
$newest = $now - 1200;
|
||
$oldest = $now - $cancelHours * 3600;
|
||
|
||
$orderList = $this->dao->getDb()
|
||
->where('status_code', OrderCenter::STATUS_PENDING_PAYMENT)
|
||
->where('pay_status', OrderCenter::PAY_UNPAID)
|
||
->where('is_del', 0)
|
||
->where('add_time', '>=', $oldest)
|
||
->where('add_time', '<=', $newest)
|
||
->field('id,order_no,uid,add_time')
|
||
->select()->toArray();
|
||
if (empty($orderList)) {
|
||
return 0;
|
||
}
|
||
|
||
$dispatched = 0;
|
||
foreach ($orderList as $order) {
|
||
// 缓存防重:同一订单在提醒间隔内只入队一次(间隔 2 小时)
|
||
$cacheKey = 'unpaid_order_remind_' . md5((string)$order['order_no']);
|
||
if (CacheService::get($cacheKey)) {
|
||
continue;
|
||
}
|
||
CacheService::set($cacheKey, 1, 7200);
|
||
UnpaidOrderSend::dispatch([(int)$order['id']]);
|
||
$dispatched++;
|
||
}
|
||
return $dispatched;
|
||
}
|
||
|
||
/**
|
||
* 首页头部统计数据
|
||
* 返回销售额、订单量、新增用户数、商品访问量四组统计数据的今日/昨日值、日环比增长率及总计
|
||
* @return array
|
||
*/
|
||
public function homeStatics(): array
|
||
{
|
||
$todayStart = strtotime(date('Y-m-d'));
|
||
$todayEnd = $todayStart + 86400;
|
||
$yesterdayStart = $todayStart - 86400;
|
||
$yesterdayEnd = $todayStart;
|
||
$date = date('Y-m-d');
|
||
|
||
// 1. 已支付订单统计(销售额 + 订单量)
|
||
$todayOrderStats = $this->dao->getDb()
|
||
->where('pay_status', 1)
|
||
->whereBetween('pay_time', [$todayStart, $todayEnd])
|
||
->field('COUNT(*) as count, COALESCE(SUM(pay_price), 0) as amount')
|
||
->find();
|
||
|
||
$yesterdayOrderStats = $this->dao->getDb()
|
||
->where('pay_status', 1)
|
||
->whereBetween('pay_time', [$yesterdayStart, $yesterdayEnd])
|
||
->field('COUNT(*) as count, COALESCE(SUM(pay_price), 0) as amount')
|
||
->find();
|
||
|
||
$totalOrderStats = $this->dao->getDb()
|
||
->where('pay_status', 1)
|
||
->field('COUNT(*) as count, COALESCE(SUM(pay_price), 0) as amount')
|
||
->find();
|
||
|
||
// 2. 新增用户统计
|
||
$todayNewUser = User::whereBetween('add_time', [$todayStart, $todayEnd])->count();
|
||
$yesterdayNewUser = User::whereBetween('add_time', [$yesterdayStart, $yesterdayEnd])->count();
|
||
$totalUser = User::count();
|
||
|
||
// 3. 商品访问量统计
|
||
$todayVisits = StoreVisit::whereBetween('add_time', [$todayStart, $todayEnd])->count();
|
||
$yesterdayVisits = StoreVisit::whereBetween('add_time', [$yesterdayStart, $yesterdayEnd])->count();
|
||
$totalVisits = StoreVisit::count();
|
||
|
||
// 组装数据
|
||
$info = [
|
||
[
|
||
'title' => '销售额',
|
||
'date' => $date,
|
||
'today' => (float)($todayOrderStats['amount'] ?? 0),
|
||
'yesterday' => (float)($yesterdayOrderStats['amount'] ?? 0),
|
||
'today_ratio' => 0,
|
||
'total_name' => '总金额',
|
||
'total' => (float)($totalOrderStats['amount'] ?? 0),
|
||
],
|
||
[
|
||
'title' => '订单量',
|
||
'date' => $date,
|
||
'today' => (int)($todayOrderStats['count'] ?? 0),
|
||
'yesterday' => (int)($yesterdayOrderStats['count'] ?? 0),
|
||
'today_ratio' => 0,
|
||
'total_name' => '总订单数',
|
||
'total' => (int)($totalOrderStats['count'] ?? 0),
|
||
],
|
||
[
|
||
'title' => '新增用户',
|
||
'date' => $date,
|
||
'today' => $todayNewUser,
|
||
'yesterday' => $yesterdayNewUser,
|
||
'today_ratio' => 0,
|
||
'total_name' => '总用户数',
|
||
'total' => $totalUser,
|
||
],
|
||
[
|
||
'title' => '商品访问量',
|
||
'date' => $date,
|
||
'today' => $todayVisits,
|
||
'yesterday' => $yesterdayVisits,
|
||
'today_ratio' => 0,
|
||
'total_name' => '总访问量',
|
||
'total' => $totalVisits,
|
||
],
|
||
];
|
||
|
||
// 计算日环比增长率(昨日为 0 时:今日有值记 100%,否则 0)
|
||
foreach ($info as &$item) {
|
||
$nowValue = (float)$item['today'];
|
||
$lastValue = (float)$item['yesterday'];
|
||
if ($lastValue == 0.0 && $nowValue == 0.0) {
|
||
$item['today_ratio'] = 0;
|
||
} elseif ($lastValue == 0.0) {
|
||
$item['today_ratio'] = 100;
|
||
} elseif ($nowValue == 0.0) {
|
||
$item['today_ratio'] = -100;
|
||
} else {
|
||
$item['today_ratio'] = (float)bcmul(bcdiv((string)($nowValue - $lastValue), (string)$lastValue, 4), '100', 2);
|
||
}
|
||
}
|
||
unset($item);
|
||
|
||
return $info;
|
||
}
|
||
|
||
/**
|
||
* 订单图表
|
||
* 根据周期统计已支付订单的订单量与销售额,用于后台首页图表展示
|
||
* @param string $cycle thirtyday=近30天;week/month/year=上期与本期对比
|
||
* @return array 返回图表数据(legend/yAxis/xAxis/series 为 ECharts 结构)
|
||
*/
|
||
public function orderCharts(string $cycle): array
|
||
{
|
||
switch ($cycle) {
|
||
case 'week':
|
||
return $this->buildCompareOrderCharts(
|
||
['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
|
||
['上周金额', '本周金额', '上周订单数', '本周订单数'],
|
||
date('Y-m-d', strtotime('-1 week Monday')),
|
||
date('Y-m-d 23:59:59', strtotime('-1 week Sunday')),
|
||
date('Y-m-d', (time() - ((date('w') == 0 ? 7 : date('w')) - 1) * 24 * 3600)),
|
||
date('Y-m-d 23:59:59'),
|
||
'week',
|
||
static fn($k) => (string)(int)$k
|
||
);
|
||
case 'month':
|
||
$days = [];
|
||
for ($i = 1; $i <= 31; $i++) {
|
||
$key = str_pad((string)$i, 2, '0', STR_PAD_LEFT);
|
||
$days[$key] = (string)$i;
|
||
}
|
||
return $this->buildCompareOrderCharts(
|
||
$days,
|
||
['上月金额', '本月金额', '上月订单数', '本月订单数'],
|
||
date('Y-m-01', strtotime('-1 month')),
|
||
date('Y-m-d 23:59:59', strtotime(date('Y-m-01')) - 1),
|
||
date('Y-m-01'),
|
||
date('Y-m-d 23:59:59'),
|
||
'month'
|
||
);
|
||
case 'year':
|
||
return $this->buildCompareOrderCharts(
|
||
[
|
||
'01' => '一月', '02' => '二月', '03' => '三月', '04' => '四月',
|
||
'05' => '五月', '06' => '六月', '07' => '七月', '08' => '八月',
|
||
'09' => '九月', '10' => '十月', '11' => '十一月', '12' => '十二月',
|
||
],
|
||
['去年金额', '今年金额', '去年订单数', '今年订单数'],
|
||
date('Y-01-01', strtotime('-1 year')),
|
||
date('Y-12-31 23:59:59', strtotime('-1 year')),
|
||
date('Y-01-01'),
|
||
date('Y-12-31 23:59:59'),
|
||
'year'
|
||
);
|
||
case 'thirtyday':
|
||
default:
|
||
return $this->buildThirtydayOrderCharts();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 近 30 天订单金额/订单数图表(补齐缺失日期)
|
||
*/
|
||
protected function buildThirtydayOrderCharts(): array
|
||
{
|
||
$dateBefore = date('Y-m-d', strtotime('-29 day'));
|
||
$dateAfter = date('Y-m-d 23:59:59');
|
||
$preBefore = date('Y-m-d', strtotime('-59 day'));
|
||
$preAfter = date('Y-m-d 23:59:59', strtotime('-30 day'));
|
||
|
||
$datalist = [];
|
||
for ($i = -29; $i <= 0; $i++) {
|
||
$label = date('m-d', strtotime($i . ' day'));
|
||
$datalist[$label] = $label;
|
||
}
|
||
|
||
$orderMap = [];
|
||
foreach ($this->dao->orderCharts($dateBefore, $dateAfter, '30') as $row) {
|
||
$orderMap[$row['day']] = $row;
|
||
}
|
||
|
||
$counts = [];
|
||
$prices = [];
|
||
$xAxis = [];
|
||
$maxNum = 0;
|
||
$maxPrice = 0;
|
||
foreach ($datalist as $day) {
|
||
$count = (int)($orderMap[$day]['count'] ?? 0);
|
||
$price = round((float)($orderMap[$day]['price'] ?? 0), 2);
|
||
$xAxis[] = $day;
|
||
$counts[] = $count;
|
||
$prices[] = $price;
|
||
$maxNum = max($maxNum, $count);
|
||
$maxPrice = max($maxPrice, $price);
|
||
}
|
||
|
||
$legend = ['订单金额', '订单数'];
|
||
$chartdata = [
|
||
'legend' => $legend,
|
||
'xAxis' => $xAxis,
|
||
'yAxis' => ['maxnum' => $maxNum, 'maxprice' => $maxPrice],
|
||
'series' => [
|
||
['name' => $legend[0], 'type' => 'bar', 'itemStyle' => $this->orderChartBarStyle('#69cdff', '#1495eb'), 'data' => $prices],
|
||
['name' => $legend[1], 'type' => 'line', 'itemStyle' => $this->orderChartBarStyle('#6fdeab', '#2cc981'), 'data' => $counts, 'yAxisIndex' => 1],
|
||
],
|
||
];
|
||
return array_merge($chartdata, $this->buildOrderCycleSummary($preBefore, $preAfter, $dateBefore, $dateAfter));
|
||
}
|
||
|
||
/**
|
||
* 周/月/年:上期 vs 本期对比图表
|
||
* @param array $axisLabels 轴标签(索引或关联)
|
||
* @param array $legend 图例四项
|
||
* @param string $preBefore 上期开始
|
||
* @param string $preAfter 上期结束
|
||
* @param string $nowBefore 本期开始
|
||
* @param string $nowAfter 本期结束
|
||
* @param string $timeType DAO 分组类型
|
||
* @param callable|null $keyNormalizer 将查询 day 规范为轴 key
|
||
*/
|
||
protected function buildCompareOrderCharts(
|
||
array $axisLabels,
|
||
array $legend,
|
||
string $preBefore,
|
||
string $preAfter,
|
||
string $nowBefore,
|
||
string $nowAfter,
|
||
string $timeType,
|
||
?callable $keyNormalizer = null
|
||
): array {
|
||
$preMap = [];
|
||
foreach ($this->dao->orderCharts($preBefore, $preAfter, $timeType) as $row) {
|
||
$key = $keyNormalizer ? $keyNormalizer($row['day']) : (string)$row['day'];
|
||
$preMap[$key] = $row;
|
||
}
|
||
$nowMap = [];
|
||
foreach ($this->dao->orderCharts($nowBefore, $nowAfter, $timeType) as $row) {
|
||
$key = $keyNormalizer ? $keyNormalizer($row['day']) : (string)$row['day'];
|
||
$nowMap[$key] = $row;
|
||
}
|
||
|
||
$xAxis = [];
|
||
$preCount = [];
|
||
$prePrice = [];
|
||
$nowCount = [];
|
||
$nowPrice = [];
|
||
$maxNum = 0;
|
||
$maxPrice = 0;
|
||
foreach ($axisLabels as $key => $label) {
|
||
$axisKey = is_int($key) ? (string)$key : (string)$key;
|
||
$labelText = is_array($label) ? ($label[0] ?? $axisKey) : (string)$label;
|
||
$pc = (int)($preMap[$axisKey]['count'] ?? 0);
|
||
$pp = round((float)($preMap[$axisKey]['price'] ?? 0), 2);
|
||
$nc = (int)($nowMap[$axisKey]['count'] ?? 0);
|
||
$np = round((float)($nowMap[$axisKey]['price'] ?? 0), 2);
|
||
$xAxis[] = $labelText;
|
||
$preCount[] = $pc;
|
||
$prePrice[] = $pp;
|
||
$nowCount[] = $nc;
|
||
$nowPrice[] = $np;
|
||
$maxNum = max($maxNum, $pc, $nc);
|
||
$maxPrice = max($maxPrice, $pp, $np);
|
||
}
|
||
|
||
$chartdata = [
|
||
'legend' => $legend,
|
||
'xAxis' => $xAxis,
|
||
'yAxis' => ['maxnum' => $maxNum, 'maxprice' => $maxPrice],
|
||
'series' => [
|
||
['name' => $legend[0], 'type' => 'bar', 'itemStyle' => $this->orderChartBarStyle('#69cdff', '#1495eb'), 'data' => $prePrice],
|
||
['name' => $legend[1], 'type' => 'bar', 'itemStyle' => $this->orderChartBarStyle('#6fdeab', '#2cc981'), 'data' => $nowPrice],
|
||
['name' => $legend[2], 'type' => 'line', 'itemStyle' => $this->orderChartBarStyle('#69cdff', '#1495eb'), 'data' => $preCount, 'yAxisIndex' => 1],
|
||
['name' => $legend[3], 'type' => 'line', 'itemStyle' => $this->orderChartBarStyle('#6fdeab', '#2cc981'), 'data' => $nowCount, 'yAxisIndex' => 1],
|
||
],
|
||
];
|
||
return array_merge($chartdata, $this->buildOrderCycleSummary($preBefore, $preAfter, $nowBefore, $nowAfter));
|
||
}
|
||
|
||
/**
|
||
* 组装上期/本期汇总(环比)
|
||
*/
|
||
protected function buildOrderCycleSummary(string $preBefore, string $preAfter, string $nowBefore, string $nowAfter): array
|
||
{
|
||
$preTotal = $this->dao->preTotalFind($preBefore, $preAfter);
|
||
$total = $this->dao->preTotalFind($nowBefore, $nowAfter);
|
||
$preCount = (int)($preTotal['count'] ?? 0);
|
||
$prePrice = (float)($preTotal['price'] ?? 0);
|
||
$nowCount = (int)($total['count'] ?? 0);
|
||
$nowPrice = (float)($total['price'] ?? 0);
|
||
$chaCount = $preCount - $nowCount;
|
||
$chaPrice = round($prePrice, 2) - round($nowPrice, 2);
|
||
$baseCount = $preCount == 0 ? 1 : $preCount;
|
||
$basePrice = $prePrice == 0 ? 1 : $prePrice;
|
||
return [
|
||
'pre_cycle' => [
|
||
'count' => ['data' => $preCount],
|
||
'price' => ['data' => $prePrice],
|
||
],
|
||
'cycle' => [
|
||
'count' => [
|
||
'data' => $nowCount,
|
||
'percent' => round((abs($chaCount) / $baseCount) * 100, 2),
|
||
'is_plus' => $chaCount > 0 ? -1 : ($chaCount == 0 ? 0 : 1),
|
||
],
|
||
'price' => [
|
||
'data' => $nowPrice,
|
||
'percent' => round((abs($chaPrice) / $basePrice) * 100, 2),
|
||
'is_plus' => $chaPrice > 0 ? -1 : ($chaPrice == 0 ? 0 : 1),
|
||
],
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* ECharts 柱/线渐变样式
|
||
*/
|
||
protected function orderChartBarStyle(string $startColor, string $endColor): array
|
||
{
|
||
return [
|
||
'normal' => [
|
||
'color' => [
|
||
'x' => 0, 'y' => 0, 'x2' => 0, 'y2' => 1,
|
||
'colorStops' => [
|
||
['offset' => 0, 'color' => $startColor],
|
||
['offset' => 1, 'color' => $endColor],
|
||
],
|
||
],
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 构建关键词搜索闭包(后台订单列表/统计共用)
|
||
* 按 search_field 限定搜索字段:order_no=订单号前缀、contact_name=收货人、
|
||
* contact_phone=联系电话、uid=用户ID精确匹配;
|
||
* all/其它=用户相关(收货人+电话,纯数字兼查 UID),订单号走独立 order_no 条件
|
||
* 收货人/电话存于 extra_info JSON,走 JSON 路径匹配
|
||
* @param string $keyword 关键词(调用方需先 trim)
|
||
* @param string $searchField 搜索字段标识
|
||
* @return \Closure 可放入 where 数组或 ->where() 的闭包
|
||
*/
|
||
public function buildKeywordFilterClosure(string $keyword, string $searchField = 'all'): \Closure
|
||
{
|
||
$escaped = addcslashes($keyword, '%_');
|
||
return function ($query) use ($escaped, $keyword, $searchField) {
|
||
$query->where(function ($q) use ($escaped, $keyword, $searchField) {
|
||
switch ($searchField) {
|
||
case 'order_no':
|
||
// 仅按订单号前缀搜索
|
||
$q->where('order_no', 'LIKE', "{$escaped}%");
|
||
break;
|
||
case 'contact_name':
|
||
// 仅按收货人姓名搜索
|
||
$q->whereRaw("extra_info->>'$.contact.name' LIKE ?", ["%{$escaped}%"]);
|
||
break;
|
||
case 'contact_phone':
|
||
// 仅按联系电话搜索
|
||
$q->whereRaw("extra_info->>'$.contact.phone' LIKE ?", ["%{$escaped}%"]);
|
||
break;
|
||
case 'uid':
|
||
// 用户ID精确匹配
|
||
$q->where('uid', '=', (int) $keyword);
|
||
break;
|
||
default:
|
||
// 用户搜索「全部」:收货人 + 电话;纯数字时兼查 UID(订单号由独立筛选项承担)
|
||
$q->whereRaw("extra_info->>'$.contact.name' LIKE ?", ["%{$escaped}%"])
|
||
->whereOrRaw("extra_info->>'$.contact.phone' LIKE ?", ["%{$escaped}%"]);
|
||
if (ctype_digit((string) $keyword)) {
|
||
$q->whereOr('uid', '=', (int) $keyword);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 后台订单列表(带完整筛选和售后状态合并)
|
||
* 支持多维度筛选和搜索,返回分页数据
|
||
* @param array $where 查询条件
|
||
* @param int $page 页码
|
||
* @param int $limit 每页数量
|
||
* @return array 返回分页数据(total、list)
|
||
*/
|
||
public function getAdminList(array $where, int $page, int $limit): array
|
||
{
|
||
$searchWhere = [];
|
||
|
||
// 通用:is_deleted=1 时为「已删除」tab,只查用户已删且后台未删的订单;
|
||
// 常规 tab 用户删除与后台删除均不显示
|
||
if (!empty($where['is_deleted'])) {
|
||
$searchWhere[] = ['is_del', '=', 1];
|
||
$searchWhere[] = ['is_system_del', '=', 0];
|
||
} else {
|
||
$searchWhere[] = ['is_del', '=', 0];
|
||
$searchWhere[] = ['is_system_del', '=', 0];
|
||
}
|
||
|
||
// 订单大类筛选
|
||
if (!empty($where['order_category'])) {
|
||
$searchWhere[] = ['order_category', '=', $where['order_category']];
|
||
}
|
||
// 产品类型筛选
|
||
if (!empty($where['product_type'])) {
|
||
$searchWhere[] = ['product_type', '=', $where['product_type']];
|
||
}
|
||
// 活动类型筛选
|
||
if (!empty($where['activity_type'])) {
|
||
$searchWhere[] = ['activity_type', '=', $where['activity_type']];
|
||
}
|
||
// 统一状态码筛选
|
||
if (!empty($where['status_code'])) {
|
||
$searchWhere[] = ['status_code', '=', $where['status_code']];
|
||
}
|
||
// 支付方式筛选
|
||
if (!empty($where['pay_type'])) {
|
||
$searchWhere[] = ['pay_type', '=', $where['pay_type']];
|
||
}
|
||
// 用户ID筛选
|
||
if (!empty($where['uid'])) {
|
||
$searchWhere[] = ['uid', '=', $where['uid']];
|
||
}
|
||
// 订单号独立搜索框:前缀匹配,与其它条件 AND 组合
|
||
$orderNo = trim((string) ($where['order_no'] ?? ''));
|
||
if ($orderNo !== '') {
|
||
$searchWhere[] = ['order_no', 'LIKE', addcslashes($orderNo, '%_') . '%'];
|
||
}
|
||
// 关键词搜索(search_field 指定字段:订单号、收货人姓名、联系电话、UID;联系信息走 extra_info JSON 路径)
|
||
$keyword = trim((string) ($where['keyword'] ?? ''));
|
||
if ($keyword !== '') {
|
||
$searchWhere[] = $this->buildKeywordFilterClosure($keyword, (string) ($where['search_field'] ?? 'all'));
|
||
}
|
||
// 日期范围筛选
|
||
if (!empty($where['date_range'])) {
|
||
[$start, $end] = $this->parseDateRange($where['date_range']);
|
||
$searchWhere[] = ['add_time', 'BETWEEN', [$start, $end]];
|
||
}
|
||
|
||
// 售后状态筛选:all=全部,processing=售后中,refunded=已退款完成,none=无售后
|
||
$afterSaleFilter = $where['after_sale'] ?? '';
|
||
|
||
// 扩展条件:闭包不能直接放入 where 数组(ThinkPHP 数组模式不解析闭包),需单独应用
|
||
$extraConditions = $where['extra_conditions'] ?? [];
|
||
|
||
// 查询字段
|
||
$fields = 'id, order_no, source_type, source_id, uid, order_category, product_type,
|
||
activity_type, status_code, pay_status, refund_status,
|
||
total_price, pay_price, pay_postage, refund_price, pay_type,
|
||
total_num, title, image, subtitle, shipping_type,
|
||
extra_info,
|
||
remark, detail_route,
|
||
add_time, pay_time, delivery_time, take_time, refund_time, cancel_time, is_del, is_system_del';
|
||
|
||
// 售后状态筛选:需要先按其他条件筛选出候选订单号,再按售后状态过滤,保证分页准确
|
||
$afterSaleOrderNos = [];
|
||
// all=所有有售后记录,processing=售后中,refunded=已退款完成,none=无售后
|
||
$needAfterSaleFilter = in_array($afterSaleFilter, ['processing', 'refunded', 'none', 'all']);
|
||
if ($needAfterSaleFilter) {
|
||
$candidateQuery = $this->dao->getDb()->where($searchWhere);
|
||
$orderNos = $where['_order_nos'] ?? [];
|
||
if (!empty($orderNos)) {
|
||
$candidateQuery->whereIn('order_no', $orderNos);
|
||
}
|
||
foreach ($extraConditions as $closure) {
|
||
$candidateQuery->where($closure);
|
||
}
|
||
$candidateOrderNos = $candidateQuery->column('order_no');
|
||
$matchedOrderNos = $this->getAfterSaleOrderNos($afterSaleFilter, $candidateOrderNos);
|
||
|
||
if ($afterSaleFilter === 'none') {
|
||
$afterSaleOrderNos = array_values(array_diff($candidateOrderNos, $matchedOrderNos));
|
||
} else {
|
||
$afterSaleOrderNos = $matchedOrderNos;
|
||
}
|
||
|
||
if (empty($afterSaleOrderNos)) {
|
||
return ['total' => 0, 'list' => []];
|
||
}
|
||
}
|
||
|
||
// 扩展表子状态(如待发货/待收货)经 _order_nos 两步筛选;不可因无 extra_conditions 而丢弃
|
||
$orderNos = $where['_order_nos'] ?? [];
|
||
|
||
if (empty($extraConditions)) {
|
||
// 无闭包扩展条件:直接走 DAO 方法
|
||
$finalWhere = $searchWhere;
|
||
if (!empty($orderNos)) {
|
||
$finalWhere[] = ['order_no', 'IN', $orderNos];
|
||
}
|
||
if (!empty($afterSaleOrderNos)) {
|
||
$finalWhere[] = ['order_no', 'IN', $afterSaleOrderNos];
|
||
}
|
||
$list = $this->dao->selectList($finalWhere, $fields, $page, $limit, 'id DESC');
|
||
$total = $this->dao->count($finalWhere);
|
||
} else {
|
||
// 有闭包扩展条件:用 DAO 提供的查询构建器,避免直接 Db::table
|
||
$query = $this->dao->getDb()->where($searchWhere);
|
||
|
||
// 扩展表条件用两步查询(先查 order_no,再 IN),不 JOIN
|
||
if (!empty($orderNos)) {
|
||
$query->whereIn('order_no', $orderNos);
|
||
}
|
||
if (!empty($afterSaleOrderNos)) {
|
||
$query->whereIn('order_no', $afterSaleOrderNos);
|
||
}
|
||
|
||
foreach ($extraConditions as $closure) {
|
||
$query->where($closure);
|
||
}
|
||
$list = $query->field($fields)->order('id DESC')->page($page, $limit)->select()->toArray();
|
||
|
||
// count 需另起查询
|
||
$countQuery = $this->dao->getDb()->where($searchWhere);
|
||
if (!empty($orderNos)) {
|
||
$countQuery->whereIn('order_no', $orderNos);
|
||
}
|
||
if (!empty($afterSaleOrderNos)) {
|
||
$countQuery->whereIn('order_no', $afterSaleOrderNos);
|
||
}
|
||
foreach ($extraConditions as $closure) {
|
||
$countQuery->where($closure);
|
||
}
|
||
$total = $countQuery->count();
|
||
}
|
||
|
||
// 转换状态码为中文文本
|
||
foreach ($list as &$item) {
|
||
// 订单状态
|
||
$item['status_text'] = OrderCenter::$statusMap[$item['status_code']] ?? '未知';
|
||
// 支付状态
|
||
$item['pay_status_text'] = OrderCenter::$payStatusMap[$item['pay_status']] ?? '未知';
|
||
// 退款状态
|
||
$item['refund_status_text'] = OrderCenter::$refundStatusMap[$item['refund_status']] ?? '未知';
|
||
// 支付方式
|
||
$item['pay_type_name'] = $this->getPayTypeName($item['pay_type']);
|
||
// 活动类型含义文案:normal 非活动显示空,其他从交易模式注册中心取标签,未注册的兜底原始值
|
||
$activityType = (string)($item['activity_type'] ?? '');
|
||
$item['activity_type_text'] = '';
|
||
if ($activityType && $activityType !== 'normal') {
|
||
try {
|
||
$tradeModeRegistry = app()->make(TradeModeRegistry::class);
|
||
$item['activity_type_text'] = $tradeModeRegistry->has($activityType)
|
||
? $tradeModeRegistry->get($activityType)->getLabel()
|
||
: $activityType;
|
||
} catch (\Throwable $e) {
|
||
$item['activity_type_text'] = $activityType;
|
||
}
|
||
}
|
||
// 订单扩展信息
|
||
$item['extra_info'] = $this->parseExtraInfo($item['extra_info']);
|
||
$item['business_status'] = $item['extra_info']['business_status'] ?? null;
|
||
// 提取联系人信息到顶层,供列表页展示
|
||
$item['contact_name'] = $item['extra_info']['contact']['name'] ?? '';
|
||
$item['contact_phone'] = $item['extra_info']['contact']['phone'] ?? '';
|
||
// 提取地址到顶层
|
||
$addr = $item['extra_info']['contact']['address'] ?? '';
|
||
$item['user_address'] = is_array($addr)
|
||
? implode('', array_filter([
|
||
$addr['province'] ?? '',
|
||
$addr['city'] ?? '',
|
||
$addr['district'] ?? '',
|
||
$addr['detail'] ?? '',
|
||
]))
|
||
: ($addr ?: '');
|
||
unset($item['extra_info']['business_status']);
|
||
}
|
||
|
||
// 批量注入下单用户昵称/电话,供列表页"用户信息"列展示
|
||
$uids = array_values(array_unique(array_filter(array_column($list, 'uid'))));
|
||
if (!empty($uids)) {
|
||
$userMap = User::where('uid', 'in', $uids)->column('nickname,phone', 'uid');
|
||
foreach ($list as &$item) {
|
||
$user = $userMap[$item['uid']] ?? [];
|
||
$item['nickname'] = $user['nickname'] ?? '';
|
||
$item['user_phone'] = $user['phone'] ?? '';
|
||
}
|
||
unset($item);
|
||
}
|
||
|
||
// 批量查询并合并售后状态到列表
|
||
$orderNos = array_column($list, 'order_no');
|
||
$afterSaleSummary = $this->getAfterSaleSummary($orderNos);
|
||
foreach ($list as &$item) {
|
||
$summary = $afterSaleSummary[$item['order_no']] ?? null;
|
||
$item['after_sale'] = $summary ?: [
|
||
'has_after_sale' => false,
|
||
'count' => 0,
|
||
'types' => [],
|
||
'statuses' => [],
|
||
'is_processing' => false,
|
||
];
|
||
// 兼容旧字段,列表页可直接判断 refund_status / is_after_sale
|
||
$item['is_after_sale'] = $item['after_sale']['has_after_sale'];
|
||
$item['is_after_sale_processing'] = $item['after_sale']['is_processing'];
|
||
}
|
||
|
||
// 通过事件机制,允许扩展模块注入额外数据
|
||
$eventData = ['list' => &$list];
|
||
event('OrderCenterListExtend', $eventData);
|
||
$list = $eventData['list'];
|
||
|
||
return compact('total', 'list');
|
||
}
|
||
|
||
/**
|
||
* 批量获取订单售后概要
|
||
* @param array $orderNos 订单号数组
|
||
* @return array 返回以 order_no 为键的售后概要
|
||
*/
|
||
protected function getAfterSaleSummary(array $orderNos): array
|
||
{
|
||
if (empty($orderNos)) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
/** @var AfterSaleDao $afterSaleDao */
|
||
$afterSaleDao = $this->app->make(AfterSaleDao::class);
|
||
return $afterSaleDao->getBatchSummary($orderNos);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] 批量查询售后概要失败: ' . $e->getMessage());
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据售后状态获取订单号列表
|
||
* @param string $mode 筛选模式:processing=售后中,refunded=已退款完成,none=无售后(此处返回有售后的订单号供 none 取差集)
|
||
* @param array $candidateOrderNos 候选订单号范围
|
||
* @return array 返回符合条件的订单号数组
|
||
*/
|
||
protected function getAfterSaleOrderNos(string $mode, array $candidateOrderNos): array
|
||
{
|
||
if (empty($candidateOrderNos)) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
/** @var AfterSaleDao $afterSaleDao */
|
||
$afterSaleDao = $this->app->make(AfterSaleDao::class);
|
||
return $afterSaleDao->getOrderNosByAfterSaleStatus($mode, $candidateOrderNos);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('[OrderCenter] 按售后状态查询订单号失败: ' . $e->getMessage());
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取订单统计数据
|
||
* 包含订单总数、金额统计、状态分布、分类统计,支持缓存
|
||
* @param array $where 查询条件
|
||
* @return array 返回统计数据
|
||
*/
|
||
public function getStatistics(array $where): array
|
||
{
|
||
$cacheKey = $this->statCacheKey($where);
|
||
$ttl = empty($where['date_range']) ? 300 : 60;
|
||
|
||
return app()->cache->remember($cacheKey, function () use ($where) {
|
||
$baseWhere = [['is_del', '=', 0], ['is_system_del', '=', 0]];
|
||
// 按订单大类筛选
|
||
if (!empty($where['order_category'])) {
|
||
$baseWhere[] = ['order_category', '=', $where['order_category']];
|
||
}
|
||
// 按产品类型筛选
|
||
if (!empty($where['product_type'])) {
|
||
$baseWhere[] = ['product_type', '=', $where['product_type']];
|
||
}
|
||
// 按活动类型筛选
|
||
if (!empty($where['activity_type'])) {
|
||
$baseWhere[] = ['activity_type', '=', $where['activity_type']];
|
||
}
|
||
// 按日期范围筛选
|
||
if (!empty($where['date_range'])) {
|
||
[$start, $end] = $this->parseDateRange($where['date_range']);
|
||
$baseWhere[] = ['add_time', 'BETWEEN', [$start, $end]];
|
||
}
|
||
// 按支付方式筛选
|
||
if (!empty($where['pay_type'])) {
|
||
$baseWhere[] = ['pay_type', '=', $where['pay_type']];
|
||
}
|
||
// 按订单号搜索(独立订单号搜索框,前缀匹配,与列表口径一致)
|
||
$statOrderNo = trim((string) ($where['order_no'] ?? ''));
|
||
if ($statOrderNo !== '') {
|
||
$baseWhere[] = ['order_no', 'LIKE', addcslashes($statOrderNo, '%_') . '%'];
|
||
}
|
||
// 按关键词搜索(search_field 指定字段:订单号、联系人姓名、联系电话、UID)
|
||
$statKeyword = trim((string) ($where['keyword'] ?? ''));
|
||
if ($statKeyword !== '') {
|
||
$baseWhere[] = $this->buildKeywordFilterClosure($statKeyword, (string) ($where['search_field'] ?? 'all'));
|
||
}
|
||
|
||
// 按状态统计订单数和金额
|
||
$statusStats = $this->dao->getDb()
|
||
->where($baseWhere)
|
||
->field('status_code, COUNT(*) as count, SUM(pay_price) as amount')
|
||
->group('status_code')
|
||
->select()
|
||
->toArray();
|
||
|
||
// 按分类统计订单数和金额
|
||
$categoryStats = $this->dao->getDb()
|
||
->where($baseWhere)
|
||
->field('order_category, COUNT(*) as count, SUM(pay_price) as amount')
|
||
->group('order_category')
|
||
->select()
|
||
->toArray();
|
||
|
||
// 统计总数和总金额
|
||
$all = $this->dao->getDb()
|
||
->where($baseWhere)
|
||
->field('COUNT(*) as count, SUM(pay_price) as amount')
|
||
->find();
|
||
|
||
// 「已删除」tab 计数:用户删除(is_del=1)且后台未删(is_system_del=0),与列表体口径一致,
|
||
// 随大类/日期筛选联动
|
||
$deletedWhere = [['is_del', '=', 1], ['is_system_del', '=', 0]];
|
||
if (!empty($where['order_category'])) {
|
||
$deletedWhere[] = ['order_category', '=', $where['order_category']];
|
||
}
|
||
if (!empty($where['date_range'])) {
|
||
[$start, $end] = $this->parseDateRange($where['date_range']);
|
||
$deletedWhere[] = ['add_time', 'BETWEEN', [$start, $end]];
|
||
}
|
||
$deletedCount = $this->dao->getDb()->where($deletedWhere)->count();
|
||
|
||
// 补全所有状态码,缺值填0
|
||
$statusCounts = [];
|
||
foreach (OrderCenter::$statusMap as $code => $label) {
|
||
$hit = array_values(array_filter($statusStats, fn($s) => $s['status_code'] === $code));
|
||
$statusCounts[$code] = [
|
||
'count' => (int) ($hit[0]['count'] ?? 0),
|
||
'amount' => (string) ($hit[0]['amount'] ?? '0.00'),
|
||
];
|
||
}
|
||
|
||
// 补全所有订单大类,缺值填0
|
||
$categoryMap = [];
|
||
foreach ($categoryStats as $row) {
|
||
$categoryMap[$row['order_category']] = $row;
|
||
}
|
||
$categoryResult = [];
|
||
foreach (OrderTypeRegistry::getCategoryMap() as $cat => $label) {
|
||
$hit = $categoryMap[$cat] ?? null;
|
||
$categoryResult[] = [
|
||
'order_category' => $cat,
|
||
'count' => (int) ($hit['count'] ?? 0),
|
||
'amount' => (string) ($hit['amount'] ?? '0.00'),
|
||
];
|
||
}
|
||
|
||
return [
|
||
'all' => [
|
||
'count' => (int) ($all['count'] ?? 0),
|
||
'amount' => (string) ($all['amount'] ?? '0.00'),
|
||
],
|
||
'status_counts' => $statusCounts,
|
||
'by_category' => $categoryResult,
|
||
'deleted_count' => $deletedCount,
|
||
];
|
||
}, $ttl);
|
||
}
|
||
|
||
/**
|
||
* 更新订单备注
|
||
* @param int $id 订单中台记录ID
|
||
* @param string $remark 备注内容
|
||
* @return bool 返回是否更新成功
|
||
*/
|
||
public function updateRemark(int $id, string $remark): bool
|
||
{
|
||
return $this->dao->update(['id' => $id], ['remark' => $remark]) !== false;
|
||
}
|
||
|
||
/**
|
||
* 软删除订单(管理端)
|
||
* 写 is_system_del 仅隐藏后台列表,不影响用户端;仅允许后台删除用户端已删除的订单
|
||
* @param int $id 订单中台记录ID
|
||
* @return bool 返回是否删除成功
|
||
* @throws ApiException 订单不存在或用户未删除时抛出
|
||
*/
|
||
public function delete(int $id): bool
|
||
{
|
||
$orderInfo = $this->dao->get(['id' => $id]);
|
||
if (!$orderInfo) {
|
||
throw new ApiException('订单不存在');
|
||
}
|
||
if (!(int)($orderInfo['is_del'] ?? 0)) {
|
||
throw new ApiException('订单未被用户删除,不能删除');
|
||
}
|
||
$res = $this->dao->update(['id' => $id], ['is_system_del' => 1]) !== false;
|
||
$this->clearStatCache();
|
||
return $res;
|
||
}
|
||
|
||
/**
|
||
* 设置线下支付方式
|
||
* 将订单支付方式改为线下支付,可选去除邮费
|
||
* @param string $orderNo 订单号
|
||
* @return bool 返回操作结果
|
||
*/
|
||
public function setOrderTypePayOffline(string $orderNo)
|
||
{
|
||
if (($count = strpos($orderNo, '_')) !== false) {
|
||
$orderNo = substr($orderNo, $count + 1);
|
||
}
|
||
// 从中台表查询订单
|
||
$orderInfo = $this->dao->get(['order_no' => $orderNo]);
|
||
if (!$orderInfo) {
|
||
return false;
|
||
}
|
||
$orderInfo = $orderInfo->toArray();
|
||
|
||
if (sys_config('offline_postage', 0) == 1) {
|
||
$res = $this->dao->update(['order_no' => $orderNo], [
|
||
'pay_type' => 'offline',
|
||
'pay_price' => bcsub((string)($orderInfo['pay_price'] ?? '0.00'), (string)($orderInfo['pay_postage'] ?? '0.00'), 2),
|
||
'pay_postage' => 0,
|
||
]);
|
||
} else {
|
||
$res = $this->dao->update(['order_no' => $orderNo], ['pay_type' => 'offline']);
|
||
}
|
||
// 线下支付仅切换支付方式,不重复触发下单后置事件(订单创建时已触发 OrderCreateAfter)
|
||
return $res;
|
||
}
|
||
|
||
/**
|
||
* 获取用户订单列表(用户详情-订单tab)
|
||
* @param int $uid 用户ID
|
||
* @param array $where 额外查询条件
|
||
* @param int $page 页码
|
||
* @param int $limit 每页条数
|
||
* @return array ['list' => [...], 'count' => int]
|
||
*/
|
||
public function getUserList(int $uid, array $where = [], int $page = 1, int $limit = 20): array
|
||
{
|
||
$where['uid'] = $uid;
|
||
// 已删除订单不进用户消费记录(与订单中台列表口径一致)
|
||
$where['is_del'] = 0;
|
||
$where['is_system_del'] = 0;
|
||
// uid/is_del/is_system_del 无搜索器,须走字段过滤:search=false 时 count 会丢弃非搜索器条件导致总数虚高
|
||
$count = $this->dao->count($where, true);
|
||
$list = $this->dao->selectList($where, '*', $page, $limit, 'id desc', [], true);
|
||
foreach ($list as &$item) {
|
||
$item['pay_type_name'] = $this->getPayTypeName($item['pay_type'] ?? '');
|
||
$item['status_text'] = \app\model\order\OrderCenter::$statusMap[$item['status_code'] ?? ''] ?? '未知';
|
||
$item['pay_status_text'] = \app\model\order\OrderCenter::$payStatusMap[$item['pay_status'] ?? 0] ?? '未知';
|
||
$item['refund_status_text'] = \app\model\order\OrderCenter::$refundStatusMap[$item['refund_status'] ?? 0] ?? '未知';
|
||
// 解析扩展信息并提取收货人,供用户消费记录展示(与订单中台列表口径一致)
|
||
$item['extra_info'] = $this->parseExtraInfo($item['extra_info']);
|
||
$item['contact_name'] = $item['extra_info']['contact']['name'] ?? '';
|
||
}
|
||
return compact('list', 'count');
|
||
}
|
||
|
||
}
|