吴昊天 300b1d8677 fix(会员价): SKU/详情透传 vip_price,网盘商品独立会员价组件
兼容 real_price 的 member_price 字段,避免会员价为 0 时开通条不展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:24:27 +08:00

759 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace modules\product\normal;
use app\dao\product\CenterProductDao;
use crmeb\trade\order\aftersale\AfterSaleHandlerInterface;
use crmeb\trade\product\BaseProductTypeHandler;
use modules\product\normal\dao\NormalOrderDao;
use modules\product\normal\dao\NormalProductDao;
use modules\product\normal\dao\NormalProductSkuDao;
use modules\product\normal\handler\NormalOrderAdapter;
use modules\product\normal\service\FreightConfigHelper;
/**
* 实物商品处理器
* 默认产品类型,当前为适配层,内部调用 app 现有服务
* 后续逐步把 app 逻辑内化到本类
*/
class Handler extends BaseProductTypeHandler
{
/**
* 普通商品的类型级能力声明。
*
* delivery 表示该商品类型支持的能力,具体商品是否启用某种配送方式,
* 仍由 getDeliveryCapability() 根据商品中台配置返回。
*/
public function getCapabilities(): array
{
return [
'pricing' => $this->getPricingPolicy(),
'delivery' => [
'express' => true,
'pickup' => true,
'door_service' => true,
],
'inventory' => [
'check_stock' => true,
],
'order' => [
'virtual' => false,
'status_group' => 'delivery',
],
];
}
/**
* 普通商品支持完整的通用价格能力。
*/
public function getPricingPolicy(): array
{
return [
'member_discount' => true,
'vip_discount' => true,
'coupon' => true,
'integral' => true,
'promotion' => true,
];
}
/**
* 类型标识
*/
public function getTypeKey(): string
{
return 'normal';
}
/**
* 订单中台 source_type
*/
public function getOrderSourceType(): string
{
return 'normal_order';
}
/**
* 类型名称
*/
public function getTypeName(): string
{
return '实物商品';
}
/**
* 额外必填字段
*/
public function getRequiredFields(): array
{
return [
'cate_id' => '请选择商品分类',
];
}
/**
* 商品删除回调
* 清理实物商品扩展表与 SKU 表数据
*/
public function afterProductDelete(int $productId): void
{
try {
/** @var NormalProductDao $productDao */
$productDao = app(NormalProductDao::class);
$productDao->delete(['product_id' => $productId]);
} catch (\Throwable $e) {
// 扩展表不存在时静默处理
}
try {
/** @var NormalProductSkuDao $skuDao */
$skuDao = app(NormalProductSkuDao::class);
$skuDao->delete(['product_id' => $productId]);
} catch (\Throwable $e) {
// SKU 表不存在时静默处理
}
}
/**
* 活动兼容性:实物商品支持所有交易模式
*/
public function canCombineWith(string $tradeMode): bool
{
return in_array($tradeMode, ['normal', 'seckill', 'combination', 'bargain', 'integral'], true);
}
/**
* 获取下单用 SKU 信息
* 实物商品从独立 SKU 表 eb_pt_normal_sku 读取
* @param int $productId 中台商品ID
* @param string $skuUnique 规格唯一编码,空则取默认SKU
* @return array|null
*/
public function getSkuInfo(int $productId, string $skuUnique): ?array
{
try {
/** @var NormalProductSkuDao $normalSkuDao */
$normalSkuDao = app(NormalProductSkuDao::class);
// 指定 SKU 时必须精准匹配,不能在唯一码失效时回退到另一 SKU,避免错价/错库存。
if ($skuUnique !== '') {
$skuInfo = $normalSkuDao->getOne([
'unique_code' => $skuUnique,
'product_id' => $productId,
'is_show' => 1,
]);
if (empty($skuInfo)) {
return null;
}
} else {
// 未指定规格时优先取默认 SKU,再回退到第一条展示中的 SKU。
$skuInfo = $normalSkuDao->getOne([
'product_id' => $productId,
'is_show' => 1,
'is_default_select' => 1,
]) ?: $normalSkuDao->getOne(['product_id' => $productId, 'is_show' => 1]);
}
if (empty($skuInfo)) {
return null;
}
if (is_object($skuInfo)) {
$skuInfo = $skuInfo->toArray();
}
// SKU suk 可能为 JSON 字符串(如 {"颜色":"黑色"} 或 []),解析为可读规格文本
$suk = (string)($skuInfo['suk'] ?? '');
$sukParsed = json_decode($suk, true);
if (is_array($sukParsed)) {
$suk = empty($sukParsed) ? '默认' : implode(' / ', array_values($sukParsed));
} else {
$suk = $suk !== '' ? $suk : '默认';
}
return [
'id' => $skuInfo['id'] ?? 0,
'unique' => $skuInfo['unique_code'] ?? '',
'price' => $skuInfo['price'] ?? 0,
'ot_price' => $skuInfo['original_price'] ?? 0,
'cost' => $skuInfo['cost'] ?? 0,
'stock' => $skuInfo['stock'] ?? 0,
'image' => $skuInfo['image'] ?? '',
'suk' => $suk,
'vip_price' => $skuInfo['vip_price'] ?? 0,
'brokerage' => $skuInfo['brokerage'] ?? 0,
'brokerage_two' => $skuInfo['brokerage_two'] ?? 0,
'weight' => $skuInfo['weight'] ?? 0,
'volume' => $skuInfo['volume'] ?? 0,
'bar_code' => $skuInfo['bar_code'] ?? '',
];
} catch (\Throwable $e) {
return null;
}
}
// ===== 订单管理维度(适配层,调用 app 现有服务)=====
/**
* 生成核销码(自提订单使用)
* 公共静态方法,供所有调用方直接使用
* @return string 8位随机数字
*/
public static function generateVerifyCode(): string
{
return str_pad((string)random_int(0, 99999999), 8, '0', STR_PAD_LEFT);
}
/**
* 创建订单扩展表记录
* 写 eb_pt_normal_order,返回记录 id,供调用方在写中台表前拿到 source_id
* store_id/verify_code/shipping_type 已归属中台表实体列,扩展表不双写
* @param string $orderNo 订单号
* @param array $cartInfo 购物车信息
* @param array $context 上下文(pay_price、total_price 等)
* @return int 扩展表记录 id
*/
public function createOrderExtend(string $orderNo, array $cartInfo, array $context): int
{
// 查询商品扩展表获取安装配置
$hasInstallation = 0;
try {
$productExt = app(NormalProductDao::class)
->getOne(['product_id' => $cartInfo['product_id'] ?? 0]);
if ($productExt) {
$hasInstallation = (int)($productExt['has_installation'] ?? 0);
}
} catch (\Throwable $e) {
// 扩展表不存在时静默处理,默认为不需要安装
}
// 计算价格
$payPrice = $context['pay_price'] ?? $cartInfo['pay_price'] ?? 0;
$totalPrice = $context['total_price'] ?? $cartInfo['total_price'] ??
(($cartInfo['truePrice'] ?? 0) * ($cartInfo['cart_num'] ?? 1));
$shippingType = (int)($context['shipping_type'] ?? 1);
$deliveryType = $shippingType === 2 ? 'pickup' : ($shippingType === 3 ? 'door' : 'express');
// 写扩展表 eb_pt_normal_order
// 注意:store_id/verify_code/shipping_type 已归属中台表实体列,扩展表不双写;
// 但 delivery_type 仍保存在扩展表,供列表/详情回填展示文案。
/** @var NormalOrderDao $orderDao */
$orderDao = app(NormalOrderDao::class);
$result = $orderDao->save([
'order_no' => $orderNo,
'product_id' => $cartInfo['product_id'] ?? 0,
'uid' => $cartInfo['uid'] ?? 0,
'pay_price' => $payPrice,
'total_price' => $totalPrice,
'status' => NormalOrderAdapter::EXT_STATUS_PENDING_PAYMENT,
'has_installation' => $hasInstallation,
'activity_type' => $context['activity_type'] ?? '',
'delivery_type' => $deliveryType,
'add_time' => time(),
]);
return $result->id ?? 0;
}
/**
* 下单后处理
* 写扩展表 + 确保中台表 source_type / source_id 正确
* 新流程:先调用 createOrderExtend 写扩展表,再用扩展表ID写中台表,此处仅做幂等校验
* 旧流程:扩展表不存在时创建,中台表 source_id 为 0 或错误时回填
*/
public function afterOrder(string $orderNo, array $cartInfo, array $context): void
{
// 写扩展表(如果已存在则幂等跳过)
/** @var NormalOrderDao $orderDao */
$orderDao = app(NormalOrderDao::class);
$exist = $orderDao->getOne(['order_no' => $orderNo], 'id');
if (!$exist) {
$this->createOrderExtend($orderNo, $cartInfo, $context);
$exist = $orderDao->getOne(['order_no' => $orderNo], 'id');
}
$extendId = $exist['id'] ?? 0;
if (!$extendId) {
return;
}
// 确保中台表 source_type / source_id 正确
// 仅当 source_id 为 0 或与扩展表不一致时才回填(兼容旧流程)
$centerDao = app(\app\dao\order\OrderCenterDao::class);
$center = $centerDao->getOne(['order_no' => $orderNo], 'source_id');
if ($center && (int)($center['source_id'] ?? 0) !== $extendId) {
$centerDao->update(['order_no' => $orderNo], [
'source_type' => NormalOrderAdapter::getSourceType(),
'source_id' => $extendId,
]);
}
}
/**
* 详情扩展数据
* 商品级通用展示字段(custom_form / params_list / label_list / protection_list)
* 优先从商品中台主表 eb_product_center 读取;中台主表为空时回退到 eb_pt_normal 兼容旧数据。
*/
public function getDetailExtend(int $productId): array
{
try {
/** @var CenterProductDao $centerProductDao */
$centerProductDao = app(CenterProductDao::class);
$product = $centerProductDao->getProductField($productId, ['custom_form', 'params_list', 'label_list', 'protection_list']);
if ($product && is_object($product)) {
$product = $product->toArray();
}
if (empty($product)) {
return [];
}
$result = [
'custom_form' => [],
'params_list' => [],
'label_list' => [],
'protection_list' => [],
];
if (!empty($product['custom_form'])) {
$result['custom_form'] = is_string($product['custom_form'])
? (json_decode($product['custom_form'], true) ?: [])
: $product['custom_form'];
}
if (!empty($product['params_list'])) {
$result['params_list'] = is_string($product['params_list'])
? (json_decode($product['params_list'], true) ?: [])
: $product['params_list'];
}
if (!empty($product['label_list'])) {
$result['label_list'] = array_map('intval', explode(',', $product['label_list']));
}
if (!empty($product['protection_list'])) {
$result['protection_list'] = array_map('intval', explode(',', $product['protection_list']));
}
// 兼容旧数据:扩展表字段已迁移到中台主表,无需回退 eb_pt_normal
return $result;
} catch (\Throwable $e) {
return [];
}
}
// ===== 物流维度 =====
/**
* 获取配送能力(统一入口,减少调用方出错概率)
* @param int $productId 商品ID
* @return array ['is_express', 'is_pickup', 'is_door_service', 'door_postage', 'shipping_info']
*/
public function getDeliveryCapability(int $productId): array
{
try {
/** @var CenterProductDao $centerDao */
$centerDao = app(CenterProductDao::class);
$center = $centerDao->getProductField($productId, ['delivery_methods', 'freight_config']);
if ($center && is_object($center)) {
$center = $center->toArray();
}
if (!$center) {
return [
'is_express' => false,
'is_pickup' => false,
'is_door_service' => false,
'door_postage' => 0.00,
'shipping_info' => ['freight' => 0, 'temp_id' => 0, 'postage' => 0, 'weight' => 0, 'volume' => 0],
];
}
// 配送方式枚举数组(从 eb_product_center 读取)
$deliveryMethods = $center['delivery_methods'] ?? ['express'];
if (is_string($deliveryMethods)) {
$deliveryMethods = json_decode($deliveryMethods, true) ?: [];
}
$deliveryMethods = is_array($deliveryMethods) ? $deliveryMethods : [];
$isExpress = in_array('express', $deliveryMethods);
$isPickup = in_array('pickup', $deliveryMethods);
$isDoorService = in_array('door_service', $deliveryMethods);
// 运费配置(新双节点格式:express + door_service)
$freightConfig = FreightConfigHelper::normalize(
$center['freight_config'] ?? FreightConfigHelper::DEFAULT_CONFIG
);
// express 节点:按 type 分支处理(free/fixed/template)
$expressConfig = $freightConfig['express'] ?? ['type' => 'template'];
$expressType = (string)($expressConfig['type'] ?? 'template');
$freight = 1; // 默认包邮(express.type=free)
$tempId = 0;
$postage = 0.00;
if ($expressType === 'fixed') {
$freight = 2;
$postage = (float)($expressConfig['postage'] ?? 0);
} elseif ($expressType === 'template') {
$freight = 3;
$tempId = (int)($expressConfig['temp_id'] ?? 0);
}
// 上门费用:从 door_service 节点读取,type=free 时强制 0
$doorConfig = $freightConfig['door_service'] ?? ['type' => 'free', 'postage' => 0];
$doorPostage = ($doorConfig['type'] ?? 'free') === 'fixed'
? (float)($doorConfig['postage'] ?? 0)
: 0.00;
// weight/volume 仍从 eb_pt_normal 读取(实物专属字段)
$extendModel = app(NormalProductDao::class)->getByProductId($productId, ['weight', 'volume']);
$extendWeight = 0;
$extendVolume = 0;
if ($extendModel && is_object($extendModel)) {
$extendData = $extendModel->toArray();
$extendWeight = (float)($extendData['weight'] ?? 0);
$extendVolume = (float)($extendData['volume'] ?? 0);
}
return [
'is_express' => $isExpress,
'is_pickup' => $isPickup,
'is_door_service' => $isDoorService,
'door_postage' => $doorPostage,
'shipping_info' => [
'freight' => $freight,
'temp_id' => $tempId,
'postage' => $postage,
'weight' => $extendWeight,
'volume' => $extendVolume,
],
];
} catch (\Throwable $e) {
return [
'is_express' => false,
'is_pickup' => false,
'is_door_service' => false,
'door_postage' => 0.00,
'shipping_info' => ['freight' => 0, 'temp_id' => 0, 'postage' => 0, 'weight' => 0, 'volume' => 0],
];
}
}
/**
* 获取运费配置
* 从实物商品扩展表 eb_pt_normal 读取运费相关字段
* 使用新的 freight_config JSON 字段
*/
public function getShippingInfo(int $productId): array
{
$capability = $this->getDeliveryCapability($productId);
return $capability['shipping_info'];
}
// ===== 库存管理维度 =====
/**
* 扣减库存
* 实物商品从 eb_pt_normal_sku 表扣减(仅扣 stock,避免 inc sales 字段缺失)
* SKU 扣减成功后同步中台主表冗余总库存(列表页读 eb_product_center.stock;销量在支付成功时累加)
*/
public function decStock(int $productId, string $skuUnique, int $num): bool
{
try {
/** @var NormalProductSkuDao $dao */
$dao = app(NormalProductSkuDao::class);
$skuInfo = $this->getSkuInfo($productId, $skuUnique);
if (empty($skuInfo)) {
return false;
}
// 直接扣 stock,不依赖 decStockIncSales(避免 inc sales 字段缺失)
if (!$dao->decStock((int)($skuInfo['id'] ?? 0), $num)) {
return false;
}
app(CenterProductDao::class)->syncStock($productId, -$num);
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 回滚库存
* 实物商品向 eb_pt_normal_sku 表回滚 stock,并同步中台主表冗余总库存
*/
public function incStock(int $productId, string $skuUnique, int $num): bool
{
try {
/** @var NormalProductSkuDao $dao */
$dao = app(NormalProductSkuDao::class);
// 回滚既有订单时 SKU 可能已被下架,按唯一码精准读取但不限制 is_show。
if ($skuUnique !== '') {
$skuInfo = $dao->getOne(['unique_code' => $skuUnique, 'product_id' => $productId]);
} else {
$skuInfo = $dao->getOne(['product_id' => $productId, 'is_default_select' => 1])
?: $dao->getOne(['product_id' => $productId]);
}
if (empty($skuInfo)) {
return false;
}
if (is_object($skuInfo)) {
$skuInfo = $skuInfo->toArray();
}
if (!$dao->incStock((int)($skuInfo['id'] ?? 0), $num)) {
return false;
}
app(CenterProductDao::class)->syncStock($productId, $num);
return true;
} catch (\Throwable $e) {
return false;
}
}
// ===== 商品详情规格维度 =====
/**
* 获取详情规格数据
* 实物商品从 eb_pt_normal_sku 表构建:多规格返回完整 SKU 矩阵,单规格返回默认规格
* @param int $productId
* @return array
*/
public function getDetailSpecs(int $productId): array
{
try {
/** @var CenterProductDao $centerProductDao */
$centerProductDao = app(CenterProductDao::class);
$product = $centerProductDao->getProductField($productId, ['spec_type']);
if ($product && is_object($product)) {
$product = $product->toArray();
}
} catch (\Throwable $e) {
$product = null;
}
$specType = $product ? (int)($product['spec_type'] ?? 0) : 0;
try {
/** @var NormalProductSkuDao $normalSkuDao */
$normalSkuDao = app(NormalProductSkuDao::class);
if ($specType) {
// 多规格
$skuList = $normalSkuDao->selectList(['product_id' => $productId, 'is_show' => 1]);
$attrMap = [];
$productValue = [];
foreach ($skuList as $sku) {
if (is_object($sku)) $sku = $sku->toArray();
$suk = $sku['suk'] ?? '';
$detail = $suk ? json_decode($suk, true) : ['规格' => '默认'];
if (!is_array($detail)) $detail = ['规格' => '默认'];
foreach ($detail as $attrName => $attrVal) {
if (!isset($attrMap[$attrName])) {
$attrMap[$attrName] = ['values' => []];
}
if (!in_array($attrVal, $attrMap[$attrName]['values'])) {
$attrMap[$attrName]['values'][] = $attrVal;
}
}
$sukKey = implode(',', array_values($detail));
$productValue[$sukKey] = [
'suk' => $sukKey,
'price' => $sku['price'] ?? '0.00',
'vip_price' => $sku['vip_price'] ?? '0.00',
'stock' => $sku['stock'] ?? 0,
'image' => $sku['image'] ?? '',
'unique' => $sku['unique_code'] ?? '',
'cost' => $sku['cost'] ?? '0.00',
'ot_price' => $sku['original_price'] ?? '0.00',
'bar_code' => $sku['bar_code'] ?? '',
'is_show' => $sku['is_show'] ?? 1,
];
}
$productAttr = [];
foreach ($attrMap as $attrName => $map) {
$productAttr[] = [
'attr_name' => $attrName,
'attr_values' => $map['values'],
'attr_value' => array_map(function ($v) {
return ['attr' => $v, 'check' => false, 'pic' => ''];
}, $map['values']),
];
}
return ['productAttr' => $productAttr, 'productValue' => $productValue];
} else {
// 单规格
$sku = $normalSkuDao->getOne(['product_id' => $productId, 'is_show' => 1, 'is_default_select' => 1])
?: $normalSkuDao->getOne(['product_id' => $productId, 'is_show' => 1]);
if (is_object($sku)) $sku = $sku->toArray();
$skuPrice = $sku['price'] ?? '0.00';
$skuVipPrice = $sku['vip_price'] ?? '0.00';
$skuStock = $sku['stock'] ?? 0;
$skuImage = $sku['image'] ?? '';
$skuUnique = $sku['unique_code'] ?? '';
$skuCost = $sku['cost'] ?? '0.00';
$skuOtPrice = $sku['original_price'] ?? '0.00';
$productAttr = [
[
'attr_name' => '规格',
'attr_values' => ['默认'],
'attr_value' => [
['attr' => '默认', 'check' => false, 'pic' => $skuImage],
],
],
];
$productValue = [
'默认' => [
'suk' => '默认',
'price' => $skuPrice,
'vip_price' => $skuVipPrice,
'stock' => $skuStock,
'image' => $skuImage,
'unique' => $skuUnique,
'cost' => $skuCost,
'ot_price' => $skuOtPrice,
'bar_code' => '',
'is_show' => 1,
],
];
return ['productAttr' => $productAttr, 'productValue' => $productValue];
}
} catch (\Throwable $e) {
return ['productAttr' => [], 'productValue' => []];
}
}
// ===== 移动端页面渲染维度 =====
/**
* 列表扩展数据
* 实物商品使用标准卡片样式
*/
public function getListExtend(int $productId): array
{
return [
'card_style' => 'standard',
'type_label' => '实物商品',
'unit_name' => '件',
];
}
/**
* 页面渲染配置
* 实物商品使用中台默认 sections 配置(CenterProductServices::buildDefaultPageConfig),
* 业态差异通过 ProductDetailExtend 监听器向 data.extensions 注入 params_list/protection_list/label_list/custom_form 等字段
* 返回空数组表示走默认配置;特殊业态(票务/酒店)可在此覆盖返回完整 sections
*/
public function getPageConfig(string $scene, string $appVersion = '1.0.0'): array
{
return [];
}
// ===== 售后维度 =====
/**
* 获取售后规则处理器
* 实物商品支持完整售后流程(仅退款/退货退款/换货/补发/维修),7天无理由
* 规则与生命周期回调统一由 NormalAfterSaleHandler 提供
* @return AfterSaleHandlerInterface
*/
public function getAfterSaleRule(): AfterSaleHandlerInterface
{
return new \modules\product\normal\handler\NormalAfterSaleHandler();
}
/**
* 获取源表订单扩展数据
* 返回 pt_normal_order 扩展表记录,供售后规则判定(如 product_id 用于保修期过滤)
* @param string $orderNo 订单号
* @return array
*/
public function getOrderExtend(string $orderNo): array
{
try {
$orderDao = app(NormalOrderDao::class);
$record = $orderDao->getOne(['order_no' => $orderNo]);
return is_object($record) ? $record->toArray() : ($record ?: []);
} catch (\Throwable $e) {
return [];
}
}
/**
* 获取兼容性声明
* 实物商品支持所有交易模式,需要物流,支持售后
* @return array
*/
public function getCompatibility(): array
{
return [
'supported_trade_modes' => ['normal', 'seckill', 'combination', 'bargain', 'integral'],
'requires_shipping' => true,
'supports_pickup' => true,
'supports_after_sale' => true,
];
}
/**
* 选择契约(normal 实物:规格矩阵选择 + 可编辑弹窗 + SKU 必选 + 件为单位)
* 显式声明,作为契约参考样本
* @return array
*/
public function getSelectionSpec(): array
{
return [
'selection_model' => 'matrix',
'confirm_window' => 'editable',
'required_selections' => ['sku'],
'cart_unit' => 'item',
];
}
/**
* 加购策略契约(normal 实物:双按钮 + 多品 + 默认文案)
* @return array
*/
public function getCartStrategy(): array
{
return [
'cart_mode' => 'cart',
'multi_item' => true,
'button_text' => '',
];
}
// ===== 管理端中台维度 =====
/**
* 管理端商品编辑扩展信息
* 委托 normal 商品服务读取扩展表字段(含 weight/volume 实物专属字段)
* @param int $productId 商品ID
* @return array
*/
public function getAdminExtendInfo(int $productId): array
{
/** @var ProductServices $productServices */
$productServices = app(ProductServices::class);
return $productServices->getExtendInfo($productId);
}
/**
* 管理端保存转发
* 委托 normal 商品保存服务完成主表 + 中台表 + 扩展表的事务保存
* @param int $id 商品ID(0 为新增)
* @param array $data 商品数据
* @return int|null
*/
public function saveFromAdmin(int $id, array $data): ?int
{
/** @var ProductSaveServices $productSaveServices */
$productSaveServices = app(ProductSaveServices::class);
return $productSaveServices->save($id, $data);
}
}