CRMEB/backend/app/services/product/CenterProductServices.php
吴昊天 e712f5554c feat(商品): 网盘商品改 direct 单按钮直购,自定义表单商品禁止加入购物车
加购策略
- virtual Handler::getCartStrategy() 由 cart/多品改为 cart_mode=direct、multi_item=false,
  中台据此下发 cart_button=0,列表与详情只保留「立即购买」。
- 详情页 direct 模式不再写购物车:选中规格后跳单品直购页
  /pages/center/order/create/index?product_id=&sku_unique=&product_type=&num=;
  多规格首次点击先弹规格窗确认规格,再次点击直接下单。
- 列表卡片 cart_button=0 时展示「立即购买」,点击整行进详情。
- StoreCartServices::setCart() 增加 isDirectCartMode() 校验,new=false 时抛
  「该商品不支持加入购物车,请直接购买」,防止其他入口绕过 UI 加购。

自定义表单
- CenterProductServices::hasCustomForm() 统一判定(兼容 JSON 字符串/空数组/已解码数组),
  5 处 cart_button 计算与 cart_strategy 组装均按 direct 处理。
- StoreCartServices::setCart() 增加 hasCustomForm() 校验,new=false 时直接拦截。

商品图片
- getList/productDetail 的 image、recommend_image、slider_image 与 SKU pic
  统一 set_file_url 补全站点域名,与后台列表口径一致,修复图片裂图。
- 同步更新 .crmeb 用户端购物车文档与虚拟商品 README、详情页 mock 数据。
2026-10-23 14:16:54 +08:00

4087 lines
188 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.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
namespace app\services\product;
use app\dao\product\CenterProductDao;
use app\Request;
use crmeb\trade\mode\TradeModeRegistry;
use app\services\activity\coupon\StoreCouponUserServices;
use crmeb\basic\BaseService;
use app\services\activity\coupon\StoreCouponIssueServices;
use app\services\order\StoreCartServices;
use app\dao\order\OrderCenterDao;
use app\services\other\QrcodeServices;
use app\services\product\product\StoreDescriptionServices;
use app\services\product\product\StoreProductCateServices;
use app\services\product\product\StoreProductCouponServices;
use app\services\product\product\StoreProductReplyServices;
use app\services\product\product\StoreProductRelationServices;
use app\services\product\product\StoreProductLabelServices;
use app\services\product\product\StoreProductProtectionServices;
use app\services\product\product\CopyProductServices;
use app\services\product\product\StoreCategoryServices;
use app\services\system\SystemUserLevelServices;
use app\services\user\UserLabelServices;
use app\services\user\member\MemberCardServices;
use app\services\user\UserLevelServices;
use app\services\user\UserSearchServices;
use app\services\user\UserServices;
use crmeb\exceptions\AdminException;
use crmeb\trade\product\ProductTypeRegistry;
use app\jobs\ProductCopyJob;
use crmeb\exceptions\ApiException;
use crmeb\utils\File;
use app\services\system\config\SystemGroupDataServices;
use think\facade\Config;
/**
* 中台商品统一服务
* 由旧 CenterProductServices 迁移而来,面向 eb_product_center 中台表
* @method getOne(array $where) 获取一条数据
* @method update(int $id, array $data) 获取一条数据
* @method delete($id, ?string $key = null) 删除数据
* @method value($where, ?string $field = null) 获取字段
* @method incStockDecSales(array $where, int $num, string $stock = 'stock', string $sales = 'sales') 加库存减销量
* @method count(array $where) 获取指定条件下的数量
* @method getColumn(array $where, string $field, string $key = '') 获取某个字段数组
* @method getSearchList(array $where, int $page = 0, int $limit = 0, ?array $field = ['*']) 获取列表
* @method get(int $id, array $field) 获取一条数据
* @method getCid(int $page, int $limit) 获取一级分类ID
* @method downAdvance() 预售商品自动到期下架
*/
class CenterProductServices extends BaseService
{
public function __construct(CenterProductDao $dao)
{
$this->dao = $dao;
}
/**
* 获取顶部标签
* @param array $where
* @return array[]
* @author: 吴汐
* @email: 442384644@qq.com
* @date: 2023/8/31
*/
public function getHeader($where = [])
{
$where['cate_id'] = !empty($where['cate_id']) ? app()->make(StoreCategoryServices::class)->getColumn(['pid' => $where['cate_id']], 'id') : [];
//出售中的商品
$onsale = $this->dao->getCount($where + ['type' => 1]);
//仓库中的商品
$forsale = $this->dao->getCount($where + ['type' => 2]);
//已经售罄商品
$outofstock = $this->dao->getCount($where + ['type' => 4]);
//警戒库存商品
$policeforce = $this->dao->getCount($where + ['type' => 5, 'store_stock' => sys_config('store_stock') > 0 ? sys_config('store_stock') : 2]);
//回收站的商品
$recycle = $this->dao->getCount($where + ['type' => 6]);
return [
['type' => 1, 'name' => '出售中的商品', 'count' => $onsale],
['type' => 2, 'name' => '仓库中的商品', 'count' => $forsale],
['type' => 4, 'name' => '已经售罄商品', 'count' => $outofstock],
['type' => 5, 'name' => '警戒库存商品', 'count' => $policeforce],
['type' => 6, 'name' => '回收站的商品', 'count' => $recycle]
];
}
/**
* 获取列表
* @param array $where
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author 吴汐
* @email 442384644@qq.com
* @date 2023/02/17
*/
public function getList(array $where, int $page = 0, int $limit = 0)
{
// 中台统一列表契约:keyword 等价 store_name;is_show/is_del 为页面显式状态筛选,
// 与通用 type tab 口径互斥(type 会强制 is_show=1/is_del=0);'' 表示全部不过滤,
// 空值必须移除,避免搜索器对空值做松散比较(如 is_show='' 在 MySQL 中等值 0)
if (($where['store_name'] ?? '') === '' && ($where['keyword'] ?? '') !== '') {
$where['store_name'] = $where['keyword'];
}
unset($where['keyword']);
$hasStatusFilter = false;
foreach (['is_show', 'is_del'] as $statusKey) {
if (($where[$statusKey] ?? '') === '') {
unset($where[$statusKey]);
} else {
$hasStatusFilter = true;
}
}
if ($hasStatusFilter || (string)($where['type'] ?? '') === '') {
unset($where['type']);
}
$where['store_stock'] = sys_config('store_stock') > 0 ? sys_config('store_stock') : 2;
if ($page <= 0 || $limit <= 0) {
[$page, $limit] = $this->getPageValue();
}
$cateIds = [];
if (isset($where['cate_id']) && $where['cate_id']) {
/** @var StoreCategoryServices $storeCategory */
$storeCategory = app()->make(StoreCategoryServices::class);
$cateIds = $storeCategory->getColumn(['pid' => $where['cate_id']], 'id');
}
if ($cateIds) {
$cateIds[] = $where['cate_id'];
$where['cate_id'] = $cateIds;
}
$order_string = '';
$order_arr = ['asc', 'desc'];
if (isset($where['sales']) && in_array($where['sales'], $order_arr)) {
$order_string = 'sales ' . $where['sales'];
}
unset($where['sales']);
$list = $this->dao->getList($where, $page, $limit, $order_string);
$cateIds = implode(',', array_column($list, 'cate_id'));
/** @var StoreCategoryServices $categoryService */
$categoryService = app()->make(StoreCategoryServices::class);
$cateList = $categoryService->getCateParentAndChildName($cateIds);
$oneCateList = $categoryService->getColumn(['pid' => 0], 'id');
$parentCateList = $categoryService->getColumn([
['id', 'in', $cateIds]
], 'cate_name', 'id');
$activityExist = $this->getActivityExist(array_column($list, 'id'));
foreach ($list as &$item) {
if ($item['spec_type'] == 0) {
// 单规格商品直接从中台表字段构建默认 SKU,不再查询旧规格表
$item['attr_value'] = [
'unique' => '',
'cost' => $item['cost'] ?? 0,
'price' => $item['price'] ?? 0,
'ot_price' => $item['original_price'] ?? 0,
'stock' => $item['stock'] ?? 0,
'product_id' => $item['id'],
];
}
$cateName = array_filter($cateList, function ($val) use ($item) {
if (in_array($val['id'], explode(',', $item['cate_id']))) {
return $val;
}
});
$item['cate_name'] = [];
foreach ($cateName as $k => $v) {
$item['cate_name'][] = $v['one'] . '/' . $v['two'];
}
foreach (explode(',', $item['cate_id']) as $cate_value) {
if (in_array($cate_value, $oneCateList)) $item['cate_name'][] = $parentCateList[$cate_value];
}
$item['cate_name'] = is_array($item['cate_name']) ? implode(',', $item['cate_name']) : '';
$item['stock_attr'] = $item['stock'] > 0; //库存
$item['product_type'] = $item['product_type'] ?? 'normal';
$item['type_name'] = $this->getProductTypeName($item['product_type']);
$item['image'] = set_file_url($item['image']);
$item['slider_image'] = set_file_url($item['slider_image']);
$item['activityExist'] = $activityExist[$item['id']];
}
$count = $this->dao->count($where);
return compact('list', 'count');
}
public function getActivityExist($productIds)
{
if (!count($productIds)) return [];
// 通过活动注册中心检查商品参与活动情况,仅检查营销类交易模式(排除普通购买等基础模式),
// 活动名称由各交易模式 Handler 的 getLabel() 动态提供,新增活动类型无需改动此处
$activityRegistry = app()->make(TradeModeRegistry::class);
$marketingKeys = array_flip($activityRegistry->getMarketingKeys());
$labels = array_column($activityRegistry->allInfo(), 'label', 'key');
$exists = $activityRegistry->checkProductActivities($productIds);
$result = [];
foreach ($exists as $productId => $modeMap) {
$list = [];
foreach ($modeMap as $modeKey => $isExist) {
// 仅保留参与中的营销类交易模式,输出结构化列表供前端动态渲染
if ($isExist && isset($marketingKeys[$modeKey])) {
$list[] = ['type' => $modeKey, 'name' => $labels[$modeKey] ?? $modeKey];
}
}
$result[$productId] = $list;
}
return $result;
}
/**
* 设置商品上下架
* @param $ids
* @param $is_show
*/
public function setShow(array $ids, int $is_show)
{
if (empty($ids)) throw new AdminException('参数错误');
/** @var StoreCartServices $cartService */
$cartService = app()->make(StoreCartServices::class);
foreach ($ids as $id) {
// 获取商品当前状态
$product = $this->dao->get($id);
if ($product) {
$oldStatus = $product['is_show'];
$cartService->changeStatus($id, $is_show);
// 商品状态变更事件(只有状态真正改变时触发)
if ($oldStatus != $is_show) {
event('Product', ['status_change', [$id, $oldStatus, $is_show]]);
}
}
}
$res = $this->dao->batchUpdate($ids, ['is_show' => $is_show]);
/** @var StoreProductCateServices $storeProductCateServices */
$storeProductCateServices = app()->make(StoreProductCateServices::class);
$storeProductCateServices->batchUpdate($ids, ['status' => $is_show], 'product_id');
return true;
}
/**
* 获取规格模板
* @return array
*/
public function getRule()
{
/** @var ProductRuleServices $productRuleServices */
$productRuleServices = app()->make(ProductRuleServices::class);
$list = $productRuleServices->getList()['list'];
foreach ($list as &$item) {
$item['rule_value'] = json_decode($item['rule_value'], true);
foreach ($item['rule_value'] as $ruleKey => $ruleItem) {
foreach ($ruleItem['detail'] as $valueKey => $valueItem) {
if (is_string($valueItem)) {
$item['rule_value'][$ruleKey]['detail'][$valueKey] = ['value' => $valueItem, 'pic' => ''];
$item['rule_value'][$ruleKey]['add_pic'] = 0;
}
}
}
}
return $list;
}
/**
* 获取商品规格数据(用户端加购/下单链路)
* 替代已弃用的 StoreProductAttrServices::getProductAttrDetail,从产品类型 Handler 的新 SKU 表读取
* @param int $id 商品ID
* @param int $uid 用户ID
* @param int $type 活动类型(0=普通商品)
* @return array [productAttr, productValue]
*/
public function getProductAttrDetail(int $id, int $uid, int $type = 0)
{
$rules = $this->getProductRules($id);
$items = $rules['items'] ?? [];
$attrs = $rules['attrs'] ?? [];
// 构造 productAttr(与旧格式兼容)
$productAttr = [];
foreach ($items as $item) {
$attrName = $item['value'] ?? '';
$detailValues = $item['detail'] ?? [];
if (is_string($detailValues)) {
$detailValues = json_decode($detailValues, true) ?: [];
}
$productAttr[] = [
'attr_name' => $attrName,
'attr_values' => is_array($detailValues) ? $detailValues : [],
'attr_value' => array_map(function ($v) {
return ['attr' => $v, 'check' => false, 'pic' => ''];
}, is_array($detailValues) ? $detailValues : []),
];
}
// 构造 productValue(与旧格式兼容)
$values = [];
foreach ($attrs as $attr) {
$detail = $attr['detail'] ?? [];
$sukKey = implode(',', array_values($detail));
$values[$sukKey] = [
'suk' => $sukKey,
'price' => floatval($attr['price'] ?? 0),
'ot_price' => floatval($attr['ot_price'] ?? 0),
'cost' => floatval($attr['cost'] ?? 0),
'stock' => intval($attr['stock'] ?? 0),
'quota' => intval($attr['quota'] ?? $attr['stock'] ?? 0),
'quota_show' => intval($attr['quota_show'] ?? $attr['quota'] ?? $attr['stock'] ?? 0),
'image' => set_file_url($attr['pic'] ?? ''),
'unique' => $attr['unique'] ?? '',
'bar_code' => $attr['bar_code'] ?? '',
'weight' => floatval($attr['weight'] ?? 0),
'volume' => floatval($attr['volume'] ?? 0),
'vip_price' => floatval($attr['vip_price'] ?? 0),
'brokerage' => floatval($attr['brokerage'] ?? 0),
'brokerage_two' => floatval($attr['brokerage_two'] ?? 0),
'cart_num' => 0,
'product_stock' => intval($attr['stock'] ?? 0),
];
}
// 会员价未开启时清零
$is_vip = $this->get($id, ['is_vip']);
if (!$this->vipIsOpen(!!($is_vip['is_vip'] ?? 0))) {
foreach ($values as &$value) {
$value['vip_price'] = 0;
}
unset($value);
}
// 活动商品补充购物车数量
if ($uid && $type) {
/** @var StoreCartServices $storeCartService */
$storeCartService = app()->make(StoreCartServices::class);
$uniques = array_column($attrs, 'unique');
$cartNumList = $storeCartService->getUserCartNums($uniques, $id, $uid);
foreach ($values as &$val) {
$val['cart_num'] = $cartNumList[$val['unique']] ?? 0;
if (is_null($val['cart_num'])) $val['cart_num'] = 0;
}
unset($val);
}
return [$productAttr, $values];
}
/**
* 获取商品详情
* @param int $id
* @return array|\think\Model|null
*/
public function getInfo(int $id)
{
/** @var StoreCategoryServices $storeCatecoryService */
$storeCatecoryService = app()->make(StoreCategoryServices::class);
/** @var StoreDescriptionServices $storeDescriptionServices */
$storeDescriptionServices = app()->make(StoreDescriptionServices::class);
/** @var StoreProductCouponServices $storeProductCouponServices */
$storeProductCouponServices = app()->make(StoreProductCouponServices::class);
/** @var StoreCouponIssueServices $storeCouponIssueServices */
$storeCouponIssueServices = app()->make(StoreCouponIssueServices::class);
/** @var UserLabelServices $userLabelServices */
$userLabelServices = app()->make(UserLabelServices::class);
$data['tempList'] = $this->getTemp();
$menus = [];
foreach ($storeCatecoryService->getTierList(1) as $menu) {
$menus[] = ['value' => $menu['id'], 'label' => $menu['html'] . $menu['cate_name'], 'disabled' => $menu['pid'] == 0 ? 0 : 1]; //,'disabled'=>$menu['pid']== 0];
}
$data['cateList'] = $menus;
$productInfo = $this->dao->getInfo($id);
if ($productInfo) $productInfo = $productInfo->toArray();
else throw new AdminException('商品不存在');
// 补充中台表不存在的扩展字段(仅保留实物专属字段),通过产品类型 Handler 获取扩展信息
$typeKey = $productInfo['product_type'] ?? 'normal';
try {
/** @var ProductTypeRegistry $registry */
$registry = app()->make(ProductTypeRegistry::class);
// has 前置判断避免未注册类型回退 normal 语义
if ($registry->has($typeKey)) {
$extendInfo = $registry->get($typeKey)->getAdminExtendInfo($id);
if (!empty($extendInfo) && is_array($extendInfo)) {
// 仅从扩展表补充实物专属字段,通用字段已迁移到 eb_product_center
foreach (['weight', 'volume'] as $field) {
if (array_key_exists($field, $extendInfo) && !array_key_exists($field, $productInfo)) {
$productInfo[$field] = $extendInfo[$field];
}
}
}
}
} catch (\Throwable $e) {
// 扩展服务异常时忽略,继续使用中台基础数据
}
$couponIds = array_column($productInfo['coupons'], 'issue_coupon_id');
// is_sub(分销开关)和 is_vip(会员价开关)是独立标量字段,前端对应两个独立表单控件
$productInfo['is_sub'] = (int) ($productInfo['is_sub'] ?? 0);
$productInfo['is_vip'] = (int) ($productInfo['is_vip'] ?? 0);
$recommend = [];
// 配送方式:从 delivery_methods 解析
$deliveryMethods = $productInfo['delivery_methods'] ?? [];
if (!is_array($deliveryMethods)) {
$deliveryMethods = [];
}
$productInfo['express_shipping'] = in_array('express', $deliveryMethods);
$productInfo['store_pickup'] = in_array('pickup', $deliveryMethods);
// 是否支持上门服务(delivery_methods 含 door_service 时为 true)
$productInfo['door_service'] = in_array('door_service', $deliveryMethods);
// 上门费用:从 freight_config.door_service 节点读取(后台编辑回显)
// 新格式:door_service.type=free 时强制 0,type=fixed 时读 postage
$freightConfig = $productInfo['freight_config'] ?? [];
if (!is_array($freightConfig)) {
$freightConfig = [];
}
$doorConfig = $freightConfig['door_service'] ?? ['type' => 'free', 'postage' => 0];
$productInfo['door_service_postage'] = ($doorConfig['type'] ?? 'free') === 'fixed'
? (float)($doorConfig['postage'] ?? 0)
: 0.00;
// 推荐商品列表:中台表存储逗号分隔的ID字符串,转换为带图片的数组
$recommend_list = [];
$recommendStr = $productInfo['recommend_list'] ?? '';
if ($recommendStr !== '') {
$recommendIds = array_filter(array_map('intval', explode(',', $recommendStr)));
if (!empty($recommendIds)) {
// 走 DAO 层按 ID 列表获取精简商品信息
$list = $this->dao->getSimpleListByIds($recommendIds);
// 保持推荐顺序与提交顺序一致
$map = array_column($list, null, 'product_id');
foreach ($recommendIds as $rid) {
if (isset($map[$rid])) {
$recommend_list[] = $map[$rid];
}
}
}
}
$productInfo['video_open'] = $productInfo['video_link'] !== '' ? 1 : 0;
if (!empty($productInfo['video_link']) && (strpos($productInfo['video_link'], 'http') !== false)) {
$productInfo['seletVideo'] = 1;
} else {
$productInfo['seletVideo'] = 0;
}
$productInfo['video_link'] = empty($productInfo['video_link']) ? '' : (strpos($productInfo['video_link'], 'http') === false ? sys_config('site_url') . $productInfo['video_link'] : $productInfo['video_link']);
$productInfo['recommend_list'] = $recommend_list;
$productInfo['coupons'] = $storeCouponIssueServices->productCouponList([['id', 'in', $couponIds]], 'title,id');
$productInfo['cate_id'] = explode(',', $productInfo['cate_id']);
// 中台表使用 label_list 字段存储标签ID,读取时映射为前端编辑所需的 label_id
$labelList = $productInfo['label_list'] ?? '';
$label_id = $labelList !== '' ? explode(',', $labelList) : [];
$productInfo['label_id'] = $userLabelServices->getLabelList(['ids' => $label_id], ['id', 'label_name']);
$productInfo['give_integral'] = floatval($productInfo['give_integral'] ?? 0);
$pst = $productInfo['presale_start_time'] ?? 0;
$productInfo['presale_time'] = $pst == 0 ? [] : [date('Y-m-d H:i:s', $pst), date('Y-m-d H:i:s', $productInfo['presale_end_time'] ?? 0)];
$productInfo['description'] = $storeDescriptionServices->getDescription(['product_id' => $id, 'activity_type' => 'normal']);
$productInfo['custom_form'] = json_decode($productInfo['custom_form'] ?? '', true) ?? [];
$productInfo['params_list'] = json_decode($productInfo['params_list'] ?? '', true) ?? [];
$productInfo['label_list'] = $productInfo['label_list'] !== '' ? array_map('intval', explode(',', $productInfo['label_list'])) : [];
$productInfo['protection_list'] = $productInfo['protection_list'] !== '' ? array_map('intval', explode(',', $productInfo['protection_list'])) : [];
// 通过 ProductTypeHandler 获取规格数据,不再读取旧三表
$typeKey = $productInfo['product_type'] ?? 'normal';
try {
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
$handler = $registry->get($typeKey);
$specs = $handler->getDetailSpecs($id);
$productAttr = $specs['productAttr'] ?? [];
$productValue = $specs['productValue'] ?? [];
$skuInfo = $handler->getSkuInfo($id, '');
if ($skuInfo === null) {
$skuInfo = [];
}
} catch (\Throwable $e) {
$productAttr = [];
$productValue = [];
$skuInfo = [];
}
if ($productInfo['spec_type'] == 1 && !empty($productAttr) && !empty($productValue)) {
// 多规格:转换 Handler 规格结构为后台编辑格式
$items = [];
foreach ($productAttr as $attrItem) {
$attrValues = $attrItem['attr_value'] ?? [];
$detail = [];
foreach ($attrValues as $v) {
$detail[] = [
'value' => is_array($v) ? ($v['attr'] ?? '') : (string)$v,
'pic' => is_array($v) ? ($v['pic'] ?? '') : '',
];
}
$items[] = [
'attr_name' => $attrItem['attr_name'] ?? '',
'detail' => $detail,
];
}
$attrs = [];
foreach ($productValue as $suk => $sku) {
$attrArr = explode(',', $suk);
$entry = [
'attr_arr' => $attrArr,
'detail' => is_array($sku['detail'] ?? null) ? $sku['detail'] : [],
'pic' => $sku['image'] ?? '',
'price' => floatval($sku['price'] ?? 0),
'cost' => floatval($sku['cost'] ?? 0),
'ot_price' => floatval($sku['ot_price'] ?? 0),
'stock' => intval($sku['stock'] ?? 0),
'bar_code' => $sku['bar_code'] ?? '',
'bar_code_number' => $sku['bar_code_number'] ?? '',
'unique' => $sku['unique'] ?? '',
'weight' => floatval($sku['weight'] ?? 0),
'volume' => floatval($sku['volume'] ?? 0),
'brokerage' => floatval($sku['brokerage'] ?? 0),
'brokerage_two' => floatval($sku['brokerage_two'] ?? 0),
'vip_price' => floatval($sku['vip_price'] ?? 0),
'is_show' => $sku['is_show'] ?? 1,
];
$num = 1;
foreach ($attrArr as $v) {
$entry['value' . $num] = $v;
$num++;
}
$attrs[] = $entry;
}
$productInfo['items'] = $items;
$productInfo['attrs'] = $attrs;
$productInfo['attr'] = [
'pic' => '', 'vip_price' => 0, 'price' => 0, 'cost' => 0, 'ot_price' => 0,
'stock' => 0, 'bar_code' => '', 'bar_code_number' => '', 'weight' => 0,
'volume' => 0, 'brokerage' => 0, 'brokerage_two' => 0,
];
} else {
// 单规格:直接使用 Handler 返回的默认 SKU 信息
$productInfo['items'] = [];
$productInfo['attrs'] = [];
$productInfo['attr'] = [
'pic' => $skuInfo['image'] ?? '',
'vip_price' => floatval($skuInfo['vip_price'] ?? 0),
'price' => floatval($skuInfo['price'] ?? 0),
'cost' => floatval($skuInfo['cost'] ?? 0),
'ot_price' => floatval($skuInfo['ot_price'] ?? 0),
'stock' => floatval($skuInfo['stock'] ?? 0),
'bar_code' => $skuInfo['bar_code'] ?? '',
'bar_code_number' => $skuInfo['bar_code_number'] ?? '',
'virtual_list' => [],
'weight' => floatval($skuInfo['weight'] ?? 0),
'volume' => floatval($skuInfo['volume'] ?? 0),
'brokerage' => floatval($skuInfo['brokerage'] ?? 0),
'brokerage_two' => floatval($skuInfo['brokerage_two'] ?? 0),
'coupon_id' => 0,
'coupon_name' => '',
'disk_info' => '',
'unique' => $skuInfo['unique'] ?? '',
];
}
// activity 字段已从中台表移除,使用空字符串兜底
$activityRaw = $productInfo['activity'] ?? '';
if ($activityRaw !== '' && $activityRaw !== null) {
$activity = is_string($activityRaw) ? explode(',', $activityRaw) : (array)$activityRaw;
foreach ($activity as $k => $v) {
if ($v == 1) {
$activity[$k] = '秒杀';
} elseif ($v == 2) {
$activity[$k] = '砍价';
} elseif ($v == 3) {
$activity[$k] = '拼团';
} elseif ($v == 0) {
$activity[$k] = '默认';
}
}
$productInfo['activity'] = $activity;
} else {
$productInfo['activity'] = ['默认', '秒杀', '砍价', '拼团'];
}
$data['productInfo'] = $productInfo;
return $data;
}
/**
* 商品详情(用户端,兼容旧版扁平结构)
* 返回旧版 goods_details/index.vue 消费的扁平字段:storeInfo / productAttr / productValue / reply / good_list 等
* ProductDetailExtend 事件注入的扩展数据(label_list/protection_list/params_list/custom_form)自动写入 storeInfo
* @param int $id 商品ID(中台商品 product_id)
* @param int $uid 用户ID
* @param string $activityType 活动类型(normal/integral/seckill/combination/bargain),默认 normal
* @param int $activityId 活动商品ID(如积分商品ID),activity_type 非 normal 时使用
* @return array 扁平结构,对应旧版接口格式
* @throws \RuntimeException 商品不存在
*/
public function getDetail(int $id, int $uid, string $activityType = 'normal', int $activityId = 0): array
{
// ========== 1. 中台商品基础数据 ==========
$info = $this->dao->getOne(['id' => $id, 'is_show' => 1, 'is_del' => 0]);
if (!$info) {
throw new \RuntimeException('商品不存在');
}
$info = is_object($info) ? $info->toArray() : $info;
$siteUrl = sys_config('site_url');
$productType = $info['product_type'] ?? 'normal';
// 会员商品可见性校验:vip_product=1 仅会员可见可买,非会员直接拦截详情页访问(防分享链接绕过列表过滤)
$vipProduct = (int)($info['vip_product'] ?? 0);
$isMoneyLevel = 0;
if (in_array($vipProduct, [1, 2], true) && $uid > 0) {
$isMoneyLevel = (int)app()->make(UserServices::class)->value(['uid' => $uid], 'is_money_level');
// vip_product=1:仅会员可见可买,非会员直接拦截(列表已过滤,此处防分享链接直达)
if ($vipProduct === 1 && $isMoneyLevel === 0) {
throw new ApiException('此商品为会员专享,开通会员后可查看');
}
}
// 通过 Handler 获取产品类型处理器
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
$handler = $registry->get($productType);
// ========== 2. storeInfo 基础信息(列名映射 → 旧版字段名)==========
$sliderImages = $info['slider_image']
? (is_array($info['slider_image'])
? $info['slider_image']
: (json_decode($info['slider_image'], true) ?: explode(',', $info['slider_image'])))
: [];
$sliderImages = array_map(function ($img) use ($siteUrl) {
return set_file_url(trim($img), $siteUrl);
}, $sliderImages);
$videoLink = empty($info['video_link']) ? '' : (
strpos($info['video_link'], 'http') === false ? $siteUrl . $info['video_link'] : $info['video_link']
);
$description = '';
try {
$descDao = app(\app\dao\product\product\StoreDescriptionDao::class);
$descRow = $descDao->getDescription(['product_id' => $id, 'activity_type' => 'normal']);
if ($descRow) {
if (is_object($descRow)) $descRow = $descRow->toArray();
$description = htmlspecialchars_decode($descRow['description'] ?? '');
}
} catch (\Throwable $e) {}
$sales = ($info['sales'] ?? 0) + ($info['virtual_sales'] ?? 0);
// storeInfo 按旧版字段名构建,eb_product_center 新列名做映射
$storeInfo = [
// ID/图片
'id' => $info['id'] ?? 0,
// 中台商品ID与产品类型:供详情页下单链路(goCat 跳转订单确认页)使用
'product_id' => $info['id'] ?? 0,
'product_type' => $productType,
'image' => set_file_url($info['image'] ?? '', $siteUrl),
'image_base' => set_file_url($info['image'] ?? '', $siteUrl),
'slider_image' => $sliderImages,
'recommend_image' => $info['image'] ?? '',
// 基本信息
'store_name' => $info['name'] ?? '', // eb_product_center.name → store_name
'store_info' => $info['intro'] ?? '', // eb_product_center.intro → store_info
'keyword' => $info['keyword'] ?? '',
'bar_code' => $info['bar_code'] ?? '',
'cate_id' => $info['cate_id'] ?? '',
'unit_name' => $info['unit_name'] ?? '件',
// 价格
'price' => $info['price'] ?? '0.00',
'vip_price' => $info['vip_price'] ?? '0.00',
'ot_price' => $info['original_price'] ?? '0.00', // original_price → ot_price
'postage' => $info['postage'] ?? '0.00',
'cost' => $info['cost'] ?? '0.00',
'give_integral' => $info['give_integral'] ?? '0.00',
// 库存/销量
'stock' => $info['stock'] ?? 0,
'sales' => $sales,
'fsales' => $sales,
'ficti' => $info['virtual_sales'] ?? 0, // virtual_sales → ficti
'browse' => $info['views'] ?? 0, // views → browse
// 排序
'sort' => $info['sort_order'] ?? 0, // sort_order → sort
// 状态位
'is_show' => (int)($info['is_show'] ?? 0),
'is_hot' => (int)($info['is_hot'] ?? 0),
'is_benefit' => (int)($info['is_benefit'] ?? 0),
'is_best' => (int)($info['is_best'] ?? 0),
'is_new' => (int)($info['is_new'] ?? 0),
'is_virtual' => (int)($info['is_virtual'] ?? 0),
'virtual_type' => (int)($info['virtual_type'] ?? 0),
'is_postage' => (int)($info['is_postage'] ?? 0),
'is_del' => (int)($info['is_del'] ?? 0),
'is_seckill' => (int)($info['is_seckill'] ?? 0),
'is_bargain' => $info['is_bargain'] ?? null,
'is_good' => (int)($info['is_good'] ?? 0),
'is_sub' => (int)($info['is_sub'] ?? 0),
'is_vip' => (int)($info['is_vip'] ?? 0),
'is_gift' => (int)($info['is_gift'] ?? 0),
'gift_price' => $info['gift_price'] ?? '0.00',
'is_limit' => (int)($info['is_limit'] ?? 0),
'limit_type' => (int)($info['limit_type'] ?? 0),
'limit_num' => (int)($info['limit_num'] ?? 0),
'min_qty' => (int)($info['min_qty'] ?? 1),
// 规格/类型
'spec_type' => (int)($info['spec_type'] ?? 0),
'activity' => $info['activity'] ?? '',
'spu' => $info['spu'] ?? '',
'label_id' => $info['label_id'] ?? '',
// 扩展
'video_link' => $videoLink,
'command_word' => $info['command_word'] ?? '',
'recommend_list' => $info['recommend_list'] ?? '',
'vip_product' => (int)($info['vip_product'] ?? 0),
// vip_product=2 且当前用户非会员时下发 true,前端据此渲染"开通会员后可购买"引导按钮
'vip_exclusive' => ($vipProduct === 2 && $isMoneyLevel === 0),
// 预售
'presale' => (int)($info['presale'] ?? 0),
'presale_start_time' => $info['presale_start_time'] ?? '1970-01-01 08:00',
'presale_end_time' => $info['presale_end_time'] ?? '1970-01-01 08:00',
'presale_day' => (int)($info['presale_day'] ?? 0),
// 物流
'logistics' => $info['logistics'] ?? '',
'freight' => (int)($info['freight'] ?? 0),
// 时间
'add_time' => $info['add_time'] ?? 0,
// 其他
'code_path' => $info['code_path'] ?? '',
'soure_link' => $info['soure_link'] ?? '',
'temp_id' => (int)($info['temp_id'] ?? 0),
'mer_id' => (int)($info['mer_id'] ?? 0),
'mer_use' => (int)($info['mer_use'] ?? 0),
'default_sku' => $info['default_sku'] ?? '',
// 描述
'description' => $description,
// 微信码(旧版前端依赖 wechat_code)
'wechat_code' => '',
// 扩展字段(后续由 ProductDetailExtend 事件覆盖)
'custom_form' => [],
'params_list' => [],
'protection_list' => [],
'label_list' => [],
];
// ========== 3. 规格数据(Handler 负责)==========
$specs = $handler->getDetailSpecs($id);
$productAttr = $specs['productAttr'] ?? [];
$productValue = $specs['productValue'] ?? [];
// 规格图拼站点域名(Handler 返回库内相对路径;仅展示侧拼接,编辑/存储链路保持相对路径)
foreach ($productValue as &$value) {
$value['image'] = set_file_url($value['image'] ?? '', $siteUrl);
}
unset($value);
foreach ($productAttr as &$attrItem) {
if (empty($attrItem['attr_value'])) {
continue;
}
foreach ($attrItem['attr_value'] as &$attrValue) {
if (is_array($attrValue) && !empty($attrValue['pic'])) {
$attrValue['pic'] = set_file_url($attrValue['pic'], $siteUrl);
}
}
unset($attrValue);
}
unset($attrItem);
$specUnique = '';
if (count($productValue) === 1) {
$firstValue = reset($productValue);
$specUnique = $firstValue['unique'] ?? '';
}
// 收集规格图片
$attrPics = [];
foreach ($productAttr as $attrValues) {
foreach ($attrValues['attr_value'] ?? [] as $picArr) {
if (isset($picArr['pic']) && $picArr['pic'] !== '') $attrPics[] = $picArr['pic'];
}
}
$storeInfo['attrPics'] = $attrPics;
// ========== 4. 价格体系 ==========
$svipOpen = false;
try {
$memberCardService = app()->make(MemberCardServices::class);
$svipOpen = $memberCardService->isOpenMemberCard('vip_price');
} catch (\Throwable $e) {}
$svipPriceOpen = (int)sys_config('member_price_status');
$storeInfo['svip_economize_price'] = bcsub((string)$storeInfo['price'], (string)$storeInfo['vip_price'], 2);
if (!($svipOpen && sys_config('member_card_status') && $storeInfo['is_vip'] && sys_config('member_price_status', 1))) {
$storeInfo['vip_price'] = 0;
}
// ========== 5. 用户状态 ==========
$userCollect = false;
try {
$relationService = app()->make(\app\services\product\product\StoreProductRelationServices::class);
$userCollect = $relationService->isProductRelation([
'uid' => $uid, 'product_id' => $id, 'type' => 'collect', 'category' => 'product',
]);
} catch (\Throwable $e) {}
$isMoneyLevel = 0;
try {
if ($uid) {
$isMoneyLevel = app()->make(\app\services\user\UserServices::class)->value(['uid' => $uid], 'is_money_level');
}
} catch (\Throwable $e) {}
$storeInfo['userCollect'] = $userCollect;
$storeInfo['userLike'] = false;
$storeInfo['small_image'] = $storeInfo['image'];
$storeInfo = get_thumb_water($storeInfo, 'small', ['small_image']);
// ========== 6. 分销价标识 ==========
$priceName = 0;
if ($uid && (int)sys_config('brokerage_window_switch', 1)) {
$priceName = $this->getPacketPrice($storeInfo, $productValue, $uid);
}
// ========== 7. 预售状态 ==========
if ($storeInfo['presale']) {
if ($storeInfo['presale_start_time'] > time()) {
$storeInfo['presale_pay_status'] = 1;
} elseif ($storeInfo['presale_start_time'] <= time() && $storeInfo['presale_end_time'] > time()) {
$storeInfo['presale_pay_status'] = 2;
} elseif ($storeInfo['presale_end_time'] < time()) {
$storeInfo['presale_pay_status'] = 3;
} else {
$storeInfo['presale_pay_status'] = 0;
}
} else {
$storeInfo['presale_pay_status'] = 0;
}
$storeInfo['presale_start_time'] = date('Y-m-d H:i', $storeInfo['presale_start_time']);
$storeInfo['presale_end_time'] = date('Y-m-d H:i', $storeInfo['presale_end_time']);
// 自定义表单/预售/虚拟商品不展示加入购物车按钮(custom_form 在步骤 13 注入后判断)
// ========== 8. 评价 ==========
$reply = null;
$replyCount = 0;
$replyChance = 0;
try {
$replyService = app()->make(\app\services\product\product\StoreProductReplyServices::class);
$reply = get_thumb_water($replyService->getRecProductReply($id), 'big', ['pics']);
[$replyCount, $goodReply, $replyChance] = $replyService->getProductReplyData($id);
} catch (\Throwable $e) {}
// ========== 9. 优惠券 ==========
$coupons = [];
try {
$couponService = app()->make(StoreCouponIssueServices::class);
$couponRes = $couponService->getIssueCouponList($uid, ['product_id' => $id, 'type' => -1]);
$coupons = $couponRes['list'] ?? [];
} catch (\Throwable $e) {}
// ========== 10. 推荐商品 ==========
$goodList = [];
$recommendRaw = $info['recommend_list'] ?? [];
if (!empty($recommendRaw)) {
$recommendIds = is_array($recommendRaw)
? array_column($recommendRaw, 'product_id')
: array_map('intval', explode(',', (string)$recommendRaw));
try {
$goodList = $recommendIds
? $this->getProducts(['ids' => $recommendIds, 'is_del' => 0, 'is_show' => 1], 12)
: [];
} catch (\Throwable $e) {}
}
// ========== 11. 活动 ==========
$activity = [];
try {
$activity = $this->getActivityList($storeInfo, false);
} catch (\Throwable $e) {}
// ========== 12. 配置 ==========
$mapKey = sys_config('tengxun_map_key');
$storeSelfMention = (int)(sys_config('store_self_mention') ?? 0);
$routineContactType = sys_config('routine_contact_type', 0);
// ========== 13. 触发 ProductDetailExtend 事件,扩展层注入扩展数据 ==========
// 扩展数据容器(事件监听器写入 extensions.*)
$extensions = [];
// 展示块容器(事件监听器写入 display_sections,按文档 4.4 order 区间约定)
$displaySections = [];
$eventData = [
'extensions' => &$extensions,
'display_sections' => &$displaySections,
'product_type' => $productType,
'activity_type' => $activityType,
'activity_id' => $activityId,
'product_id' => $id,
'uid' => $uid,
'handler' => $handler,
'info' => $info,
'store_info' => &$storeInfo,
'product_value' => &$productValue,
'specs' => $specs,
];
//
event('ProductDetailExtend', $eventData);
// display_sections 按 order 升序排序(各监听器遵守区间约定:产品类型 10-99 / 交易模式 100-199 / 营销 200-299)
usort($displaySections, function ($a, $b) {
return ($a['order'] ?? 0) <=> ($b['order'] ?? 0);
});
// 将扩展数据注入 storeInfo(旧版前端直接从 storeInfo 读取)
if (!empty($extensions)) {
if (isset($extensions['label_list'])) {
$storeInfo['label_list'] = $extensions['label_list'];
} else {
$storeInfo['label_list'] = [];
}
if (isset($extensions['protection_list'])) {
$storeInfo['protection_list'] = $extensions['protection_list'];
} else {
$storeInfo['protection_list'] = [];
}
if (isset($extensions['params_list'])) {
$storeInfo['params_list'] = $extensions['params_list'];
} else {
$storeInfo['params_list'] = [];
}
if (isset($extensions['custom_form'])) {
$storeInfo['custom_form'] = $extensions['custom_form'];
} else {
$storeInfo['custom_form'] = [];
}
} else {
$storeInfo['label_list'] = [];
$storeInfo['protection_list'] = [];
$storeInfo['params_list'] = [];
$storeInfo['custom_form'] = [];
}
// 透传选择/加购契约到 storeInfo(前端按契约动态渲染详情页选择区与加购按钮栏)
$storeInfo['selection_spec'] = $handler->getSelectionSpec();
$storeInfo['cart_strategy'] = $handler->getCartStrategy();
// 自定义表单商品必须填写表单后直接下单,不支持加入购物车,强制 direct 单按钮模式
if ($this->hasCustomForm($storeInfo['custom_form'] ?? [])) {
$storeInfo['cart_strategy']['cart_mode'] = 'direct';
}
// cart_button 由 cart_strategy.cart_mode 决定:direct 模式不展示加入购物车按钮
// 预售商品在模板中走独立分支,不影响此处判断
$storeInfo['cart_button'] = ($storeInfo['cart_strategy']['cart_mode'] ?? 'cart') === 'direct' ? 0 : 1;
// ========== 14. 组装扁平返回数据 ==========
$result = [
'uid' => $uid,
'is_money_level' => $isMoneyLevel,
'spec_unique' => $specUnique,
'productAttr' => $productAttr,
'productValue' => $productValue,
'storeInfo' => $storeInfo,
'svip_open' => $svipOpen,
'svip_price_open' => $svipPriceOpen,
'priceName' => $priceName,
'reply' => $reply,
'replyChance' => $replyChance,
'replyCount' => $replyCount,
'mer_id' => 0,
'good_list' => $goodList,
'mapKey' => $mapKey,
'store_self_mention' => $storeSelfMention,
'activity' => $activity,
'coupons' => $coupons,
'routine_contact_type' => $routineContactType,
// 展示块数组(数据驱动详情页渲染,各产品类型监听器输出,前端 P3 阶段消费)
'display_sections' => $displaySections,
];
// ========== 15. 访问事件(二开可修改 &$result)==========
try {
event('UserVisit', [$uid, $id, 'product', $storeInfo['cate_id'], 'view']);
event('Product', ['visit', [$id, $uid, date('Y-m-d H:i:s'), &$result]]);
} catch (\Throwable $e) {}
return $result;
}
/**
* 组装默认 page_config sections 声明式渲染配置
* 根据商品数据和产品类型,生成 sections 数组,前端引擎按此配置动态渲染
* 当 Handler::getPageConfig('detail') 未提供 sections 时使用此默认配置
* @param array $data 已组装的功能域分组数据
* @return array {sections, bottom_bar, submit_api}
*/
protected function buildDefaultPageConfig(array $data): array
{
$productType = $data['product_type'] ?? 'normal';
$extensions = $data['extensions'] ?? [];
$sections = [];
// 该类型的下单表单组件由 Handler 声明(如酒店的日期范围/预约表单),中台按声明渲染,不硬编码扩展产品类型
$orderFormComponents = app()->make(ProductTypeRegistry::class)->get($productType)->getOrderFormComponents();
// 辅助:添加一个 section
$add = function (string $key, string $component, array $props, bool $visible, int $order) use (&$sections) {
$sections[] = [
'key' => $key,
'component' => $component,
'props' => $props,
'visible' => $visible,
'order' => $order,
];
};
// 10. 图片轮播
$add('carousel', 'image_carousel', ['images' => '$.product.slider_image', 'video_link' => '$.product.video_link'], true, 10);
// 15. 履约提示条(自动发货/到店核销等)
$add('notice', 'notice_bar', ['text' => '$.extensions.fulfillment_preview'], !empty($extensions['fulfillment_preview']), 15);
// 20. 商品基础信息
$add('product_info', 'product_info', ['product' => '$.product', 'price' => '$.price', 'type_info' => '$.extensions.type_info'], true, 20);
// 22. 浏览/销量/最近成交
$add('social_proof', 'social_proof', ['social_proof' => '$.social_proof'], true, 22);
// 25. 会员价标签
$add('price_tags', 'price_tags', ['price' => '$.price'], !empty($data['price']['svip_open']), 25);
// 28. 服务承诺
$add('service_promise', 'service_promise', ['promises' => '$.service_promise.promises'], !empty($data['service_promise']['promises']), 28);
// 30. 活动入口
$add('activity', 'activity_entry', ['activity' => '$.activity.activity'], !empty($data['activity']['activity']), 30);
// 35. 优惠券
$add('coupon', 'coupon_entry', ['coupons' => '$.coupon.coupons'], !empty($data['coupon']['coupons']), 35);
// 36. 营销(满减满赠)
$add('promotion', 'promotion_banner', ['marketing' => '$.marketing'], !empty($extensions['marketing']) || !empty($data['marketing']['full_reduction']), 36);
// 40. SKU 规格
$add('sku', 'sku_selector', ['productAttr' => '$.specs.productAttr', 'productValue' => '$.specs.productValue'], true, 40);
// 42. 场次选择(票务)
$add('sessions', 'session_picker', ['sessions' => '$.extensions.sessions'], !empty($extensions['sessions']), 42);
// 44. 日期库存日历(票务/酒店)
$add('date_inventory', 'date_inventory_calendar', ['date_inventory' => '$.extensions.date_inventory'], !empty($extensions['date_inventory']), 44);
// 46. 日期范围(酒店)——组件是否挂载由对应产品类型 Handler::getOrderFormComponents() 声明,中台不硬编码扩展类型
$add('date_range', 'date_range_picker', ['booking' => '$.extensions.booking'], !empty($extensions['booking']) && in_array('date_range', $orderFormComponents), 46);
// 48. 房型列表(酒店)
$add('room_types', 'room_type_list', ['room_types' => '$.extensions.room_types'], !empty($extensions['room_types']), 48);
// 50. 自定义表单
$add('custom_form', 'form_builder', ['fields' => '$.extensions.custom_form'], !empty($extensions['custom_form']), 50);
// 52. 实名表单(票务)
$add('realname', 'realname_form', ['realname' => '$.extensions.realname'], !empty($extensions['realname']), 52);
// 54. 预约表单(酒店/上门)——组件是否挂载由对应产品类型 Handler::getOrderFormComponents() 声明,中台不硬编码扩展类型
$add('booking', 'booking_form', ['booking' => '$.extensions.booking'], !empty($extensions['booking']) && in_array('booking', $orderFormComponents), 54);
// 56. 上门预约
$add('appointment', 'appointment_picker', ['booking' => '$.extensions.booking'], !empty($extensions['onsite']), 56);
// 58. 服务人员(上门)
$add('service_staff', 'service_staff', ['staffs' => '$.extensions.onsite.staffs'], !empty($extensions['onsite']), 58);
// 60. 参数列表
$add('params', 'params_list', ['params' => '$.extensions.params_list'], !empty($extensions['params_list']), 60);
// 62. 保障服务
$add('protection', 'protection_list', ['protections' => '$.extensions.protection_list'], !empty($extensions['protection_list']), 62);
// 64. 商品标签
$add('label', 'label_list', ['labels' => '$.extensions.label_list'], !empty($extensions['label_list']), 64);
// 65. 配送至(收货地址)
$add('address', 'address_selector', ['address' => '$.address'], !empty($data['shipping']['requires_shipping']), 65);
// 66. 配送/运费
$add('shipping', 'shipping_info', ['shipping' => '$.shipping'], !empty($data['shipping']['requires_shipping']), 66);
// 68. 门店信息(自提)
$add('store', 'store_info', ['store' => '$.store'], !empty($data['shipping']['supports_pickup']), 68);
// 70. 商家卡片(到店团购)
$add('merchant', 'merchant_card', ['merchant' => '$.extensions.merchant'], !empty($extensions['merchant']), 70);
// 72. 套餐内容(props key 用 package_data,引擎转驼峰为 packageData,避免 package 保留字冲突)
$add('package', 'package_content', ['package_data' => '$.extensions.package'], !empty($extensions['package']), 72);
// 74. 多门店列表
$add('stores', 'store_list', ['stores' => '$.extensions.stores'], !empty($extensions['stores']), 74);
// 76. 票务信息
$add('ticket', 'ticket_info', ['ticket' => '$.extensions.ticket'], !empty($extensions['ticket']), 76);
// 78. 核销方式
$add('verify', 'verify_info', ['verify' => '$.extensions.verify'], !empty($extensions['verify']), 78);
// 80. 设施服务
$add('facility', 'facility_list', ['facilities' => '$.extensions.facilities'], !empty($extensions['facilities']), 80);
// 82. 服务区域(上门)
$add('service_area', 'service_area', ['onsite' => '$.extensions.onsite'], !empty($extensions['onsite']), 82);
// 84. 地图位置
$add('map', 'map_card', ['location' => '$.location', 'config' => '$.config'], !empty($data['location']['latitude']), 84);
// 86. 评价区
$add('reply', 'reply_section', ['reply' => '$.reply'], !empty($data['reply']['reply']), 86);
// 88. 短视频/买家秀
$add('video', 'video_show', ['videos' => '$.extensions.video'], !empty($extensions['video']), 88);
// 90. 推荐商品
$add('recommend', 'recommend_list', ['recommend' => '$.recommend'], !empty($data['recommend']['good_list']) || !empty($data['recommend']['recommend_list']), 90);
// 92. 购买须知
$add('purchase_notice', 'purchase_notice', ['notices' => '$.purchase_notice.notices'], !empty($data['purchase_notice']['notices']), 92);
// 94. 分销悬浮框(到手价/自购省/预估佣金)
$add('spread_float', 'spread_float', ['spread' => '$.extensions.spread'], !empty($extensions['spread']) && !empty($data['config']['spread_switch']), 94);
// 95. 分享海报(生成海报+保存相册)
$add('share_poster', 'share_poster', ['spread' => '$.extensions.spread'], !empty($extensions['spread']) && !empty($data['config']['spread_switch']), 95);
// 96. 分销素材(文案/图片/视频)
$add('spread_material', 'spread_material', ['spread' => '$.extensions.spread'], !empty($extensions['spread']), 96);
// 97. 图文详情
$add('description', 'rich_text', ['content' => '$.product.description'], true, 97);
// 按 order 升序排序
usort($sections, function ($a, $b) {
return $a['order'] <=> $b['order'];
});
return [
'sections' => $sections,
'bottom_bar' => [
'text' => '立即购买',
'action' => 'submitOrder',
// cart_button 由 cart_strategy.cart_mode 决定:direct 模式不展示加入购物车按钮
// 自定义表单商品需填表后直接下单,同样不展示加入购物车
'extra' => ['cart_button' => !$this->hasCustomForm($extensions['custom_form'] ?? []) && $this->getCartModeByType($productType) !== 'direct'],
],
'submit_api' => 'center/order/create',
];
}
/**
* 获取运费模板列表
* 通过事件机制解耦,由 modules/product/normal 监听器返回数据
* @return array
*/
public function getTemp()
{
// 直接调用 app/ 层运费模板服务
/** @var \app\services\shipping\ShippingTemplatesServices $shippingServices */
$shippingServices = app()->make(\app\services\shipping\ShippingTemplatesServices::class);
return $shippingServices->getSelectList();
}
/**
* 获取商品规格
* @param array $data
* @param int $id
* @param int $type
* @return array
*/
public function getAttr(array $data, int $id, int $type)
{
/** @var StoreCouponIssueServices $storeCouponIssueServices */
$storeCouponIssueServices = app()->make(StoreCouponIssueServices::class);
$attr = $data['attrs'];
$is_virtual = $data['is_virtual']; //是否虚拟商品
$productType = $data['product_type'] ?? 'normal'; //产品类型
$attr = array_merge($attr);
list($value, $head) = attr_format($attr);
$valueNew = [];
$count = 0;
// 编辑商品时,从产品类型 Handler 读取已有 SKU 数据作为默认值
$existingSkus = [];
$skuExtraFields = [];
try {
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
$handler = $registry->get($productType);
$skuExtraFields = $handler->getSkuExtraFields();
if ($id) {
$specs = $handler->getDetailSpecs($id);
$existingSkus = $specs['productValue'] ?? [];
}
} catch (\Throwable $e) {
// 未注册类型回退到 normal,无扩展字段
}
foreach ($value as $suk) {
$detail = explode(',', $suk);
$types = 1;
if ($id) {
$sku = $existingSkus[$suk] ?? [];
if (empty($sku)) {
if ($type == 0) $types = 0; //编辑商品时,将没有规格的数据不生成默认值
$sukValue[$suk]['pic'] = '';
$sukValue[$suk]['price'] = 0;
$sukValue[$suk]['cost'] = 0;
$sukValue[$suk]['ot_price'] = 0;
$sukValue[$suk]['stock'] = 0;
$sukValue[$suk]['bar_code'] = '';
$sukValue[$suk]['bar_code_number'] = '';
if ($is_virtual) {
foreach ($skuExtraFields as $field) {
$sukValue[$suk][$field['key']] = $field['new_default'];
}
}
$sukValue[$suk]['weight'] = 0;
$sukValue[$suk]['volume'] = 0;
$sukValue[$suk]['brokerage'] = 0;
$sukValue[$suk]['brokerage_two'] = 0;
} else {
$sukValue[$suk] = [
'pic' => $sku['image'] ?? '',
'price' => $sku['price'] ?? 0,
'cost' => $sku['cost'] ?? 0,
'ot_price' => $sku['ot_price'] ?? 0,
'stock' => $sku['stock'] ?? 0,
'bar_code' => $sku['bar_code'] ?? '',
'bar_code_number' => $sku['bar_code_number'] ?? '',
'unique' => $sku['unique'] ?? '',
'weight' => $sku['weight'] ?? 0,
'volume' => $sku['volume'] ?? 0,
'brokerage' => $sku['brokerage'] ?? 0,
'brokerage_two' => $sku['brokerage_two'] ?? 0,
'vip_price' => $sku['vip_price'] ?? 0,
];
if ($is_virtual) {
foreach ($skuExtraFields as $field) {
$key = $field['key'];
$sukValue[$suk][$key] = $sku[$key] ?? $field['new_default'];
}
}
}
} else {
$sukValue[$suk]['pic'] = '';
$sukValue[$suk]['price'] = 0;
$sukValue[$suk]['cost'] = 0;
$sukValue[$suk]['ot_price'] = 0;
$sukValue[$suk]['stock'] = 0;
$sukValue[$suk]['bar_code'] = '';
$sukValue[$suk]['bar_code_number'] = '';
if ($is_virtual) {
foreach ($skuExtraFields as $field) {
$sukValue[$suk][$field['key']] = $field['new_default'];
}
}
$sukValue[$suk]['weight'] = 0;
$sukValue[$suk]['volume'] = 0;
$sukValue[$suk]['brokerage'] = 0;
$sukValue[$suk]['brokerage_two'] = 0;
}
if ($types) { //编辑商品时,将没有规格的数据不生成默认值
foreach ($head as $k => $title) {
$header[$k]['title'] = $title;
$header[$k]['align'] = 'center';
$header[$k]['minWidth'] = 130;
}
foreach ($detail as $k => $v) {
$valueNew[$count]['value' . ($k + 1)] = $v;
$header[$k]['key'] = 'value' . ($k + 1);
}
$valueNew[$count]['detail'] = array_combine($head, $detail);
$valueNew[$count]['pic'] = $sukValue[$suk]['pic'] ?? '';
$valueNew[$count]['price'] = !empty($sukValue[$suk]['price']) ? floatval($sukValue[$suk]['price']) : 0;
$valueNew[$count]['cost'] = !empty($sukValue[$suk]['cost']) ? floatval($sukValue[$suk]['cost']) : 0;
$valueNew[$count]['ot_price'] = isset($sukValue[$suk]['ot_price']) ? floatval($sukValue[$suk]['ot_price']) : 0;
$valueNew[$count]['vip_price'] = isset($sukValue[$suk]['vip_price']) ? floatval($sukValue[$suk]['vip_price']) : 0;
$valueNew[$count]['stock'] = !empty($sukValue[$suk]['stock']) ? intval($sukValue[$suk]['stock']) : 0;
$valueNew[$count]['bar_code'] = $sukValue[$suk]['bar_code'] ?? '';
$valueNew[$count]['bar_code_number'] = $sukValue[$suk]['bar_code_number'] ?? '';
if ($is_virtual) {
foreach ($skuExtraFields as $field) {
$key = $field['key'];
// 编辑模式(type=0)回填所有扩展字段;新增模式仅回填有 slot 声明的字段
if (!$type || !empty($field['slot'])) {
$valueNew[$count][$key] = $sukValue[$suk][$key] ?? $field['new_default'];
}
}
}
$valueNew[$count]['weight'] = floatval($sukValue[$suk]['weight'] ?? 0);
$valueNew[$count]['volume'] = floatval($sukValue[$suk]['volume'] ?? 0);
$valueNew[$count]['brokerage'] = floatval($sukValue[$suk]['brokerage'] ?? 0);
$valueNew[$count]['brokerage_two'] = floatval($sukValue[$suk]['brokerage_two'] ?? 0);
$count++;
}
}
$header[] = ['title' => '图片', 'slot' => 'pic', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '售价', 'slot' => 'price', 'align' => 'center', 'minWidth' => 120];
$header[] = ['title' => '成本价', 'slot' => 'cost', 'align' => 'center', 'minWidth' => 140];
$header[] = ['title' => '划线价', 'slot' => 'ot_price', 'align' => 'center', 'minWidth' => 140];
$header[] = ['title' => '库存', 'slot' => 'stock', 'align' => 'center', 'minWidth' => 140];
$header[] = ['title' => '商品编码', 'slot' => 'bar_code', 'align' => 'center', 'minWidth' => 140];
$header[] = ['title' => '条形码', 'slot' => 'bar_code_number', 'align' => 'center', 'minWidth' => 140];
if ($is_virtual) {
// 由产品类型 Handler 声明是否需要渲染网盘产品列
if (!empty($skuExtraFields)) {
$header[] = ['title' => '网盘信息', 'slot' => 'fictitious', 'align' => 'center', 'minWidth' => 140];
}
} else {
$header[] = ['title' => '重量(KG)', 'slot' => 'weight', 'align' => 'center', 'minWidth' => 140];
$header[] = ['title' => '体积(m³)', 'slot' => 'volume', 'align' => 'center', 'minWidth' => 140];
}
$header[] = ['title' => '操作', 'slot' => 'action', 'align' => 'center', 'minWidth' => 70];
return ['attr' => $attr, 'value' => $valueNew, 'header' => $header];
}
/**
* SPU
* @return string
*/
public function createSpu()
{
return substr(implode('', array_map('ord', str_split(substr(uniqid('', true), 7, 13), 1))), 0, 8) . str_pad((string)mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
}
/**
* 新增编辑商品
* @param int $id
* @param array $data
*/
public function save(int $id, array $data, bool $fromModule = false)
{
// 前端使用 label_id 字段提交标签ID,中台表实际存储字段为 label_list
if (!empty($data['label_id']) && empty($data['label_list'])) {
$data['label_list'] = is_array($data['label_id']) ? $data['label_id'] : explode(',', $data['label_id']);
}
// is_sub(分销开关)和 is_vip(会员价开关)是独立标量字段,兼容旧版数组格式
$data['is_vip'] = intval($data['is_vip'] ?? 0);
$data['is_sub'] = intval($data['is_sub'] ?? 0);
// 已迁移商品类型(普通/卡密/虚拟)通过产品类型 Handler 转发到对应模块的保存服务
if (!$fromModule) {
$productType = $data['product_type'] ?? 'normal';
/** @var ProductTypeRegistry $registry */
$registry = app()->make(ProductTypeRegistry::class);
// has 前置判断避免未注册类型回退 normal;Handler 返回 null 表示该类型无模块化保存
if ($registry->has($productType)) {
$result = $registry->get($productType)->saveFromAdmin($id, $data);
if ($result !== null) {
return $result;
}
}
}
if (count($data['cate_id']) < 1) throw new AdminException('请选择商品分类');
if (!$data['store_name']) throw new AdminException('请输入商品名称');
if (count($data['slider_image']) < 1) throw new AdminException('请选择商品轮播图');
if ($data['is_limit'] == 1 && $data['min_qty'] > $data['limit_num']) throw new AdminException('起购数量不能大于限购数量');
$detail = $data['attrs'];
$attr = $data['items'];
$cate_id = $data['cate_id'];
$coupon_ids = $data['coupon_ids'];
$description = $data['description'];
$type = $data['type'];
$data['recommend_list'] = count($data['recommend_list']) ? implode(',', array_column($data['recommend_list'], 'product_id')) : '';
$data['vip_product'] = intval($data['vip_product']);
$data['presale'] = intval($data['presale']);
$data['presale_start_time'] = $data['presale'] ? strtotime($data['presale_time'][0]) : 0;
$data['presale_end_time'] = $data['presale'] ? strtotime($data['presale_time'][1]) : 0;
// 预售发货时间(预售活动结束后N天内发货):>0 才覆盖写入,未传时新增走列默认值1、编辑保持原值
if (isset($data['presale_day']) && intval($data['presale_day']) > 0) {
$data['presale_day'] = intval($data['presale_day']);
} else {
unset($data['presale_day']);
}
$data['is_limit'] = intval($data['is_limit']);
if (!$data['is_limit']) {
$data['limit_type'] = 0;
$data['limit_num'] = 0;
} else {
if (!in_array($data['limit_type'], [1, 2])) throw new AdminException('请选择限购类型');
if ($data['limit_num'] <= 0) throw new AdminException('限购数量不能小于1');
}
// 根据产品类型 Handler 判断是否为虚拟商品
try {
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
$data['is_virtual'] = $registry->get($data['product_type'] ?? 'normal')->isVirtual() ? 1 : 0;
} catch (\Throwable $e) {
$data['is_virtual'] = 0;
}
$data['logistics'] = implode(',', $data['logistics']);
$data['custom_form'] = json_encode($data['custom_form']);
$data['params_list'] = json_encode($data['params_list']);
$data['label_list'] = implode(',', $data['label_list']);
$data['protection_list'] = implode(',', $data['protection_list']);
if ($data['freight'] == 2) {
$data['temp_id'] = 0;
} elseif ($data['freight'] == 3) {
$data['postage'] = 0;
}
foreach ($detail as &$item) {
// 未开启分销时清空 SKU 返佣字段
if (!$data['is_sub']) {
$item['brokerage'] = 0;
$item['brokerage_two'] = 0;
}
if (($item['brokerage'] + $item['brokerage_two']) > $item['price']) {
throw new AdminException('一二级返佣相加不能大于商品售价');
}
if (isset($item['detail'])) {
foreach ($item['detail'] as $detail_k => $detail_v) {
if (preg_match('/[=;]/', $detail_k) === 1 || preg_match('/[=;]/', $detail_v) === 1) {
throw new AdminException('规格以及规格值中不能包含=或;符号');
}
}
}
}
foreach ($data['activity'] as $k => $v) {
if ($v == '秒杀') {
$data['activity'][$k] = 1;
} elseif ($v == '砍价') {
$data['activity'][$k] = 2;
} elseif ($v == '拼团') {
$data['activity'][$k] = 3;
} else {
$data['activity'][$k] = 0;
}
}
if ($data['spec_type'] == 1) {
$defaultItems = array_filter($detail, function ($item) {
return $item['is_default_select'] === 1;
});
if (!empty($defaultItems)) {
$defaultItem = reset($defaultItems);
$data['price'] = $defaultItem['price'];
$data['ot_price'] = $defaultItem['ot_price'];
$data['cost'] = $defaultItem['cost'] ?? 0;
} else {
$filtered = array_filter($detail, function ($item) {
return $item['is_show'] == 1;
});
$data['price'] = min(array_column($filtered, 'price'));
$data['ot_price'] = min(array_column($filtered, 'ot_price'));
$data['cost'] = isset($data['cost']) ? min(array_column($filtered, 'cost')) : 0;
if (!$data['cost']) {
$data['cost'] = 0;
}
}
} else {
$data['price'] = min(array_column($detail, 'price'));
$data['ot_price'] = min(array_column($detail, 'ot_price'));
$data['cost'] = isset($data['cost']) ? min(array_column($detail, 'cost')) : 0;
if (!$data['cost']) {
$data['cost'] = 0;
}
}
$data['activity'] = implode(',', $data['activity']);
$data['cate_id'] = implode(',', $data['cate_id']);
// 中台表使用 label_list 字段,label_id 为前端提交字段,已在方法入口映射
$slider_image = $data['slider_image'];
$data['image'] = $data['slider_image'][0];
$data['slider_image'] = json_encode($data['slider_image']);
$data['stock'] = array_sum(array_column($detail, 'stock'));
unset($data['description'], $data['coupon_ids'], $data['items'], $data['attrs'], $data['type']);
/** @var StoreDescriptionServices $storeDescriptionServices */
$storeDescriptionServices = app()->make(StoreDescriptionServices::class);
/** @var StoreProductCateServices $storeProductCateServices */
$storeProductCateServices = app()->make(StoreProductCateServices::class);
/** @var StoreProductCouponServices $storeProductCouponServices */
$storeProductCouponServices = app()->make(StoreProductCouponServices::class);
/** @var StoreCategoryServices $storeCategoryServices */
$storeCategoryServices = app()->make(StoreCategoryServices::class);
$is_copy = $data['is_copy'] ?? 0;
unset($data['is_copy']);
$descriptionImages = [];
if (isset($data['description_images'])) {
$descriptionImages = $data['description_images'];
}
return $this->transaction(function () use ($id, $is_copy, $data, $descriptionImages, $description, $cate_id, $storeDescriptionServices, $storeProductCateServices, $storeProductCouponServices, $storeCategoryServices, $coupon_ids, $type, $slider_image) {
if ($id) {
$oldInfo = $this->get($id)->toArray();
if (($oldInfo['product_type'] ?? 'normal') != ($data['product_type'] ?? 'normal')) {
throw new AdminException('商品类型不能切换');
}
unset($data['sales']);
$this->dao->update($id, $data);
$storeDescriptionServices->saveDescription($id, $description, 'normal');
$cateData = [];
$time = time();
$cateGory = $storeCategoryServices->getColumn([['id', 'IN', $cate_id]], 'id,pid', 'id');
foreach ($cate_id as $cid) {
if ($cid && isset($cateGory[$cid]['pid'])) {
$cateData[] = ['product_id' => $id, 'cate_id' => $cid, 'cate_pid' => $cateGory[$cid]['pid'] ?: $cid, 'status' => $data['is_show'], 'add_time' => $time];
}
}
$storeProductCateServices->change($id, $cateData);
if (!empty($coupon_ids)) {
$storeProductCouponServices->setCoupon($id, $coupon_ids);
} else {
$storeProductCouponServices->delete(['product_id' => $id]);
}
// 商品更新事件
event('Product', ['update', [$id, $oldInfo, $data]]);
// 商品价格变动事件(检测价格是否变化)
if (isset($oldInfo['price']) && $oldInfo['price'] != $data['price']) {
event('Product', ['price_change', [$id, '', $oldInfo['price'], $data['price'], 'update']]);
}
return $id;
} else {
$data['add_time'] = time();
$data['code_path'] = '';
$data['spu'] = $this->createSpu();
$res = $this->dao->save($data);
$storeDescriptionServices->saveDescription($res->id, $description, 'normal');
$cateData = [];
$time = time();
$cateGory = $storeCategoryServices->getColumn([['id', 'IN', $cate_id]], 'id,pid', 'id');
foreach ($cate_id as $cid) {
if ($cid && isset($cateGory[$cid]['pid'])) {
$cateData[] = ['product_id' => $res->id, 'cate_id' => $cid, 'cate_pid' => $cateGory[$cid]['pid'], 'status' => $data['is_show'], 'add_time' => $time];
}
}
$storeProductCateServices->change($res->id, $cateData);
if (!empty($coupon_ids)) $storeProductCouponServices->setCoupon($res->id, $coupon_ids);
// 商品创建事件
event('Product', ['create', $data]);
//采集商品下载图片
if ($type == -1) {
$s_image_down = [];
//下载商品轮播图
foreach ($slider_image as $s_image) {
if (sys_config('queue_open', 0) == 1) {
ProductCopyJob::dispatch('copySliderImage', [$res->id, $s_image, count($slider_image)]);
} else {
//下载图片
$s_image_down[] = app()->make(CopyProductServices::class)->downloadCopyImage(!is_int(strpos($s_image, 'http')) ? 'http://' . ltrim($s_image, '\//') : $s_image);
}
}
//下载商品详情图
preg_match_all('#<img.*?src="([^"]*)"[^>]*>#i', $description, $match);
foreach ($match[1] as $d_image) {
if (sys_config('queue_open', 0) == 1) {
ProductCopyJob::dispatch('copyDescriptionImage', [$res->id, $description, $d_image, count($match[1])]);
} else {
$d_img = app()->make(CopyProductServices::class)->downloadCopyImage(!is_int(strpos($d_image, 'http')) ? 'http://' . ltrim($d_image, '\//') : $d_image);
$description = str_replace($d_image, $d_img, $description);
}
}
// TODO: 商品规格图下载已迁移至各产品类型 ProductSaveServices,旧规格表弃用后此处不再处理
if (sys_config('queue_open', 0) == 0) {
$this->update($res->id, ['slider_image' => $s_image_down ? json_encode($s_image_down) : '', 'image' => $s_image_down[0]]);
$storeDescriptionServices->saveDescription((int)$res->id, $description, 'normal');
}
}
return $res->id;
}
});
}
/**
* 根据商品ID获取产品类型标识
* @param int $productId
* @return string
*/
public function getProductType(int $productId): string
{
return (string)($this->dao->value(['id' => $productId], 'product_type') ?: 'normal');
}
/**
* 判断指定产品类型是否为虚拟商品
* 通过 ProductTypeRegistry 咨询对应 Handler
* @param string $typeKey
* @return bool
*/
protected function isVirtualProduct(string $typeKey): bool
{
try {
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
return $registry->get($typeKey)->isVirtual();
} catch (\Throwable $e) {
return false;
}
}
/**
* 按产品类型获取加购模式
* 咨询对应 Handler 的 getCartStrategy(),返回 cart_mode
* 静态缓存避免列表场景重复实例化 Handler
* @param string $typeKey 产品类型标识
* @return string cart_mode:cart(双按钮加购)| direct(单按钮直接下单)
*/
protected function getCartModeByType(string $typeKey): string
{
static $cache = [];
if (isset($cache[$typeKey])) {
return $cache[$typeKey];
}
try {
$registry = app()->make(ProductTypeRegistry::class);
$mode = $registry->get($typeKey)->getCartStrategy()['cart_mode'] ?? 'cart';
} catch (\Throwable $e) {
$mode = 'cart';
}
$cache[$typeKey] = $mode;
return $mode;
}
/**
* 判断商品是否配置了自定义表单
* 自定义表单商品需先填写表单才能下单,无法按普通购物车流程结算,因此不支持加入购物车
* @param mixed $customForm 中台 custom_form 原值(JSON 字符串或已解码数组)
* @return bool
*/
protected function hasCustomForm($customForm): bool
{
if (empty($customForm)) {
return false;
}
if (is_array($customForm)) {
return !empty($customForm);
}
$decoded = json_decode((string)$customForm, true);
if (is_array($decoded)) {
return !empty($decoded);
}
return trim((string)$customForm) !== '';
}
/**
* 获取产品类型展示名称
* 通过 ProductTypeRegistry 咨询对应 Handler
* @param string $typeKey
* @return string
*/
protected function getProductTypeName(string $typeKey): string
{
try {
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
return $registry->get($typeKey)->getTypeName();
} catch (\Throwable $e) {
return '普通商品';
}
}
/**
* 添加商品属性数据判断
* @param array $attrList
* @param array $valueList
* @param int $productId
* @param int $type
* @param int $validate
* @param bool $isNew
* @return array
*/
public function validateProductAttr(array $attrList, array $valueList, int $productId, $type = 0, int $validate = 1, bool $isNew = false)
{
$result = ['attr' => $attrList, 'value' => $valueList];
$attrValueList = [];
$attrNameList = [];
foreach ($attrList as $index => $attr) {
if (!isset($attr['value'])) {
throw new AdminException('请输入规则名称');
}
$attr['value'] = trim($attr['value']);
if (!isset($attr['value'])) {
throw new AdminException('请输入规则名称');
}
if (!isset($attr['detail']) || !count($attr['detail'])) {
throw new AdminException('请输入属性名称');
}
foreach ($attr['detail'] as $k => $attrValue) {
if (is_string($attrValue) || is_int($attrValue) || is_float($attrValue)) {
$attrValueTemp = (string)$attrValue;
$attrValue = [];
$attrValue['value'] = $attrValueTemp;
$attrValue['pic'] = '';
} elseif (is_array($attrValue)) {
$attrValue['value'] = trim($attrValue['value']);
$attrValue['pic'] = isset($attr['add_pic']) ? $attr['add_pic'] == 1 ? trim($attrValue['pic']) : '' : '';
}
if (empty($attrValue['value'])) {
throw new AdminException('请输入正确的属性');
}
$attr['detail'][$k] = $attrValue;
$attrValueList[] = $attrValue;
}
$attrNameList[] = $attr['value'];
$attrList[$index] = $attr;
}
$attrCount = count($attrList);
foreach ($valueList as $index => $value) {
if (!isset($value['detail']) || count($value['detail']) != $attrCount) {
throw new AdminException('请填写正确的商品信息');
}
if (!isset($value['price']) || !is_numeric($value['price']) || floatval($value['price']) != $value['price']) {
throw new AdminException('请填写正确的商品价格');
}
if ($validate && (!isset($value['stock']) || !is_numeric($value['stock']) || intval($value['stock']) != $value['stock'])) {
throw new AdminException('请填写正确的商品库存');
}
if (!isset($value['cost']) || !is_numeric($value['cost']) || floatval($value['cost']) != $value['cost']) {
throw new AdminException('请填写正确的商品成本价格');
}
if ($validate && (!isset($value['pic']) || empty($value['pic']))) {
throw new AdminException('请设置规格图片');
}
foreach ($value['detail'] as $attrName => $attrValue) {
//如果attrName 存在空格 则这个规格key 会出现两次
unset($valueList[$index]['detail'][$attrName]);
$attrName = trim($attrName);
$attrValue = trim($attrValue);
if (!in_array($attrName, $attrNameList, true)) {
throw new AdminException('{:name}规格不存在', ['name' => $attrName]);
}
if (!in_array($attrValue, array_column($attrValueList, 'value'), true)) {
throw new AdminException('{:name}属性不存在', ['name' => $attrValue]);
}
if (empty($attrName)) {
throw new AdminException('请输入正确的属性');
}
$valueList[$index]['detail'][$attrName] = $attrValue;
}
}
$attrGroup = [];
$valueGroup = [];
foreach ($attrList as $k => $value) {
$attrGroup[] = [
'product_id' => $productId,
'attr_name' => $value['value'],
'attr_values' => $value['detail'],
'type' => $type
];
}
// 已迁移到实物商品独立 SKU 表 eb_pt_normal_sku,按 suk(JSON)复用原 unique_code
//通过事件代理查询 SKU unique_code 映射,避免 app 层直接引用 modules
$results = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getColumnByProductId', 'args' => [$productId, 'unique_code', 'suk']]);
$skuArray = proxy_event_result($results, []);
foreach ($valueList as $k => $value) {
$sku = implode(',', array_map(function ($key) use ($value) {
return $value['detail'][$key];
}, array_column($attrList, 'value')));
$skuValue = [
'product_id' => $productId,
'suk' => $sku,
'price' => $value['price'],
'cost' => $value['cost'],
'ot_price' => $value['ot_price'],
'unique' => $isNew ? '' : $value['unique'] ?? '',
'image' => $value['pic'],
'bar_code' => $value['bar_code'] ?? '',
'bar_code_number' => $value['bar_code_number'] ?? '',
'weight' => $value['weight'] ?? 0,
'volume' => $value['volume'] ?? 0,
'brokerage' => $value['brokerage'] ?? 0,
'brokerage_two' => $value['brokerage_two'] ?? 0,
'type' => $type,
'quota' => $value['quota'] ?? 0,
'quota_show' => $value['quota'] ?? 0,
'vip_price' => $value['vip_price'] ?? 0,
'is_virtual' => $value['is_virtual'] ?? 0,
'coupon_id' => $value['coupon_id'] ?? 0,
'virtual_list' => $value['virtual_list'] ?? [],
'disk_info' => $value['disk_info'] ?? '',
'is_show' => $value['is_show'] ?? 1,
'is_default_select' => $value['is_default_select'] ?? 0,
];
if ($validate) {
$skuValue['stock'] = $value['stock'];
}
$valueGroup[$sku] = $skuValue;
}
if (!count($attrGroup) || !count($valueGroup)) {
throw new AdminException('请设置至少一个属性');
}
$result = ['attr' => $attrList, 'value' => $valueList];
return compact('result', 'attrGroup', 'valueGroup');
}
/**
* 放入回收站
* @param int $id
* @return string
*/
public function del(int $id)
{
if (!$id) throw new AdminException('参数错误');
$productInfo = $this->dao->get($id);
if (!$productInfo) throw new AdminException('商品不存在');
/** @var StoreProductCateServices $storeProductCateServices */
$storeProductCateServices = app()->make(StoreProductCateServices::class);
if ($productInfo['is_del'] == 1) {
$data['is_del'] = 0;
$res = $this->dao->update($id, $data);
$storeProductCateServices->update(['product_id' => $id], ['status' => 1]);
if (!$res) throw new AdminException('恢复失败');
return '恢复成功';
} else {
$data['is_del'] = 1;
$data['is_show'] = 0;
$res = $this->dao->update($id, $data);
$storeProductCateServices->update(['product_id' => $id], ['status' => 0]);
if (!$res) throw new AdminException('删除失败');
// 商品删除事件
event('Product', ['delete', [$id, $productInfo->toArray()]]);
return '移动回收站成功';
}
}
/**
* 获取选择的商品列表
* 从商品中台表(eb_product_center)查询,兼容旧 SKU 表回退
* @param array $where
* @return array
*/
public function searchList(array $where, bool $isStock = false, $is_page = true)
{
// 解析 type 筛选
$store_stock = sys_config('store_stock');
$where['store_stock'] = $store_stock > 0 ? $store_stock : 2;
// 使用商品中台表查询
/** @var \app\dao\product\CenterProductDao $centerProductDao */
$centerProductDao = app()->make(\app\dao\product\CenterProductDao::class);
[$page, $limit] = $this->getPageValue($is_page);
// 构建中台查询条件
$searchWhere = $where;
$count = $centerProductDao->count($searchWhere);
$list = $centerProductDao->getSearchList($searchWhere, $page, $limit, ['*']);
// 字段映射:中台字段 → 前端期望格式
$cateIds = [];
foreach ($list as &$item) {
$item['store_name'] = $item['name'];
$item['ot_price'] = $item['original_price'] ?? '0.00';
$item['price'] = floatval($item['price'] ?? 0);
// 中台表没有的旧字段设默认值
$item['give_integral'] = 0;
$item['vip_price'] = '0.00';
$item['postage'] = '0.00';
$item['cost'] = '0.00';
$item['is_product_type'] = 1;
$item['product_type'] = $item['product_type'] ?? 'normal';
$item['type_name'] = $this->getProductTypeName($item['product_type']);
$item['logistics'] = [];
// 相对路径图片补全站点域名,与 getList 口径保持一致(image 主图、recommend_image 推荐图、slider_image 轮播图为数组)
$item['image'] = set_file_url($item['image'] ?? '');
$item['recommend_image'] = set_file_url($item['recommend_image'] ?? '');
$item['slider_image'] = set_file_url($item['slider_image'] ?? []);
// slider_image 由模型获取器自动解码
if (isset($item['cate_id']) && $item['cate_id'] !== '') {
$cateIds[] = $item['cate_id'];
}
}
unset($item);
// 补充分类名称
if (!empty($cateIds)) {
/** @var StoreCategoryServices $storeCategoryServices */
$storeCategoryServices = app()->make(StoreCategoryServices::class);
$cateList = $storeCategoryServices->getCateArray(implode(',', array_unique($cateIds)));
foreach ($list as &$item) {
$cateName = array_filter($cateList, function ($val) use ($item) {
return $val['id'] == $item['cate_id'];
});
$item['cate_name'] = !empty($cateName) ? implode(',', array_column($cateName, 'cate_name')) : '';
}
unset($item);
} else {
foreach ($list as &$item) {
$item['cate_name'] = '';
}
unset($item);
}
// 补充商品规格(兼容旧 SKU 表 & 中台新建商品)
foreach ($list as &$item) {
$item['attrs'] = [];
$rules = $this->getProductRules($item['id'], 0);
if (!empty($rules) && isset($rules['attrs'])) {
$key = 0;
foreach ($rules['attrs'] as $items) {
$item['attrs'][$key] = $items;
$item['attrs'][$key]['suk'] = implode(',', $items['detail']);
$item['attrs'][$key]['product_price'] = $items['price'];
// 规格图片同样补全站点域名
$item['attrs'][$key]['pic'] = set_file_url($items['pic'] ?? '');
$key += 1;
}
}
}
unset($item);
return compact('count', 'list');
}
/**
* 后台获取商品列表展示
* @param array $where
* @return array
*/
public function getProductList(array $where, bool $isStock = true, $is_page = true)
{
$prefix = Config::get('database.connections.' . Config::get('database.default') . '.prefix');
if ($isStock) {
$field = [
'*',
'(SELECT count(*) FROM `' . $prefix . 'store_product_relation` WHERE `product_id` = `' . $prefix . 'store_product`.`id` AND `type` = \'collect\') as collect',
'(SELECT count(*) FROM `' . $prefix . 'store_product_relation` WHERE `product_id` = `' . $prefix . 'store_product`.`id` AND `type` = \'like\') as likes',
'(SELECT count(*) FROM `' . $prefix . 'store_visit` WHERE `product_id` = `' . $prefix . 'store_product`.`id` AND `product_type` = \'product\') as visitor',
];
} else {
$field = ['*'];
}
[$page, $limit] = $this->getPageValue($is_page);
$list = $this->dao->getSearchList($where, $page, $limit, $field, ['description']);
foreach ($list as &$product) {
$product['product_price'] = $product['price'];
$attrValue = $product['attrs'] ?? [];
foreach ($attrValue as &$value) {
$value['product_price'] = $value['price'];
}
$product['attrs'] = $attrValue;
}
$count = $this->dao->getCount($where);
return compact('count', 'list');
}
/**
* 获取商品规格
* @param int $id
* @param int $type
* @return array
*/
public function getProductRules(int $id, int $type = 0)
{
// 优先通过 ProductTypeHandler 读取实际 SKU 数据,
// 适用范围:普通商品(type=0)、活动商品(type>0 如秒杀/砍价/拼团/积分等)
// 通过读取 product_center 表的 product_type 字段确定真实产品类型(normal/virtual/cardkey),
// 使用对应 ProductTypeHandler 获取 SKU 规格数据。
try {
$typeKey = (string)($this->dao->value(['id' => $id], 'product_type') ?: 'normal');
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
$handler = $registry->get($typeKey);
$specs = $handler->getDetailSpecs($id);
$rules = $this->convertHandlerSpecsToRules($specs, $type);
if ($rules !== null) {
return $rules;
}
// 门票/酒店等类型 getDetailSpecs 返回空(规格由详情页 display_sections 承载),
// 但活动后台(积分/秒杀等)选择 SKU 需要实际票种/房型列表,回退到 getActivitySkuList
if (method_exists($handler, 'getActivitySkuList')) {
$activitySpecs = $handler->getActivitySkuList($id);
$activityRules = $this->convertHandlerSpecsToRules($activitySpecs, $type);
if ($activityRules !== null) {
return $activityRules;
}
}
} catch (\Throwable $e) {
// 异常时回退到后续方案
}
// 回退方案 1:从新 SKU 表(eb_pt_normal_sku)读取
$newSkuData = $this->getProductRulesFromNewSkuTable($id, $type);
if ($newSkuData !== null) {
return $newSkuData;
}
// 回退方案 2:中台表默认单规格
return $this->getProductRulesFallback($id, $type);
}
/**
* 将 ProductTypeHandler 返回的规格结构转换为后台规则表格格式
* 输出与 getProductRules 同构的 ['items' => ..., 'attrs' => ..., 'header' => ...]
* @param array $specs Handler 返回的 ['productAttr' => ..., 'productValue' => ...]
* @param int $type 活动类型,决定 header 列
* @return array|null 有规格数据返回数组,否则返回 null 让调用方回退
*/
protected function convertHandlerSpecsToRules(array $specs, int $type): ?array
{
$productAttr = $specs['productAttr'] ?? [];
$productValue = $specs['productValue'] ?? [];
// 无规格时交给 fallback 处理,保持默认单规格行为一致
if (empty($productAttr) || empty($productValue)) {
return null;
}
// 1. 构造 items(属性定义)
$attr = [];
$headerMap = [];
$i = 1;
foreach ($productAttr as $value) {
$attrValues = $value['attr_values'] ?? [];
if (is_string($attrValues)) {
$attrValues = json_decode($attrValues, true) ?? [];
}
$attrName = $value['attr_name'] ?? '';
$attr[] = [
'value' => $attrName,
'detailValue' => '',
'attrHidden' => true,
'detail' => is_array($attrValues) ? $attrValues : [],
];
$headerMap[$attrName] = 'value' . $i;
$i++;
}
// 2. 构造 attrs(具体 SKU 值)
$valueNew = [];
$count = 0;
foreach ($productValue as $suk => $sku) {
$detail = $sku['detail'] ?? [];
if (!is_array($detail) || empty($detail)) {
$detail = [$attr[0]['value'] ?? '规格' => $suk];
}
$entry = [];
$i = 1;
foreach ($productAttr as $attrItem) {
$attrName = $attrItem['attr_name'] ?? '';
$entry['value' . $i] = $detail[$attrName] ?? '';
$i++;
}
$entry['detail'] = $detail;
$entry['pic'] = $sku['image'] ?? '';
$entry['price'] = floatval($sku['price'] ?? 0);
$entry['cost'] = floatval($sku['cost'] ?? 0);
$entry['ot_price'] = floatval($sku['ot_price'] ?? 0);
$entry['stock'] = intval($sku['stock'] ?? 0);
$entry['quota'] = intval($sku['stock'] ?? 0);
$entry['bar_code'] = $sku['bar_code'] ?? '';
$entry['bar_code_number'] = $sku['bar_code_number'] ?? '';
$entry['unique'] = $sku['unique'] ?? '';
$entry['weight'] = floatval($sku['weight'] ?? 0);
$entry['volume'] = floatval($sku['volume'] ?? 0);
$entry['brokerage'] = floatval($sku['brokerage'] ?? 0);
$entry['brokerage_two'] = floatval($sku['brokerage_two'] ?? 0);
$entry['vip_price'] = floatval($sku['vip_price'] ?? 0);
if ($type == 2) {
$entry['min_price'] = 0;
}
if (in_array($type, [1, 2, 3])) {
$entry['r_price'] = floatval($sku['price'] ?? 0);
}
$valueNew[$count] = $entry;
$count++;
}
// 3. 构造 header(表头)
$header = [];
$i = 1;
foreach ($productAttr as $attrItem) {
$header[] = [
'title' => $attrItem['attr_name'] ?? '',
'key' => 'value' . $i,
'align' => 'center',
'minWidth' => 80,
];
$i++;
}
$header[] = ['title' => '图片', 'slot' => 'pic', 'align' => 'center', 'minWidth' => 120];
if ($type == 1) {
$header[] = ['title' => '秒杀价', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 2) {
$header[] = ['title' => '砍价起始金额', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '砍价最低价', 'slot' => 'min_price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 3) {
$header[] = ['title' => '拼团价', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 4) {
$header[] = ['title' => '兑换积分', 'slot' => 'price', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 6) {
$header[] = ['title' => '预售价', 'key' => 'price', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '划线价', 'key' => 'ot_price', 'align' => 'center', 'minWidth' => 80];
} else {
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '划线价', 'key' => 'ot_price', 'align' => 'center', 'minWidth' => 80];
}
$header[] = ['title' => '库存', 'key' => 'stock', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '限量', 'slot' => 'quota', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '重量(KG)', 'key' => 'weight', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '体积(m³)', 'key' => 'volume', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '商品编码', 'key' => 'bar_code', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '条形码', 'key' => 'bar_code_number', 'align' => 'center', 'minWidth' => 80];
return ['items' => $attr, 'attrs' => $valueNew, 'header' => $header];
}
/**
* 从新 SKU 表(eb_pt_normal_sku)读取产品多规格数据
* 迁移至 product_center 后,正常商品的多规格数据存储在新表中。
* 无论 type 参数,只要旧表无数据且新表有记录,均可作为回退来源。
* @param int $id 商品中台 ID
* @param int $type 活动类型(用于决定 header 格式,不限制查询表)
* @return array|null 有数据返回与 getProductRules 同构的数组,否则返回 null
*/
protected function getProductRulesFromNewSkuTable(int $id, int $type): ?array
{
try {
//通过事件代理查询商品启用的 SKU 列表,避免 app 层直接引用 modules
$results = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getShowListByProductId', 'args' => [$id]]);
$skus = proxy_event_result($results, []);
} catch (\Throwable $e) {
// 表不存在或查询异常,静默降级
return null;
}
if (empty($skus)) {
return null;
}
// 1. 解析所有 suk JSON,提取属性定义
$attrDefs = []; // attrName => [value => true]
foreach ($skus as $sku) {
$suk = json_decode($sku['suk'], true);
if (!is_array($suk)) continue;
foreach ($suk as $attrName => $attrValue) {
$attrDefs[$attrName][$attrValue] = true;
}
}
if (empty($attrDefs)) {
return null;
}
// 2. 构建 items(属性定义),按属性名排序保持一致性
ksort($attrDefs);
$attr = [];
$headerMap = []; // attrName => 'value1', 'value2', ...
$i = 1;
foreach ($attrDefs as $attrName => $values) {
$attr[] = [
'value' => $attrName,
'detailValue' => '',
'attrHidden' => true,
'detail' => array_keys($values),
];
$headerMap[$attrName] = 'value' . $i;
$i++;
}
// 3. 构建 attrs(具体 SKU 值)
$valueNew = [];
foreach ($skus as $sku) {
$suk = json_decode($sku['suk'], true);
if (!is_array($suk)) continue;
$entry = [];
$detail = [];
$i = 1;
foreach ($attrDefs as $attrName => $_) {
$entry['value' . $i] = $suk[$attrName] ?? '';
$detail[$attrName] = $suk[$attrName] ?? '';
$i++;
}
$entry['detail'] = $detail;
$entry['pic'] = $sku['image'] ?? '';
$entry['price'] = floatval($sku['price'] ?? 0);
$entry['cost'] = floatval($sku['cost'] ?? 0);
$entry['ot_price'] = floatval($sku['original_price'] ?? 0);
$entry['stock'] = intval($sku['stock'] ?? 0);
$entry['quota'] = intval($sku['stock'] ?? 0);
$entry['bar_code'] = $sku['bar_code'] ?? '';
$entry['bar_code_number'] = $sku['bar_code_number'] ?? '';
$entry['unique'] = $sku['unique_code'] ?? '';
$entry['weight'] = floatval($sku['weight'] ?? 0);
$entry['volume'] = floatval($sku['volume'] ?? 0);
$entry['brokerage'] = floatval($sku['brokerage'] ?? 0);
$entry['brokerage_two'] = floatval($sku['brokerage_two'] ?? 0);
$entry['vip_price'] = floatval($sku['vip_price'] ?? 0);
$valueNew[] = $entry;
}
// 4. 构建 header(表头),根据 $type 决定展示哪些列
$header = [];
$i = 1;
foreach ($attrDefs as $attrName => $_) {
$header[] = [
'title' => $attrName,
'key' => 'value' . $i,
'align' => 'center',
'minWidth' => 80,
];
$i++;
}
$header[] = ['title' => '图片', 'slot' => 'pic', 'align' => 'center', 'minWidth' => 120];
if ($type == 1) {
$header[] = ['title' => '秒杀价', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 2) {
$header[] = ['title' => '砍价起始金额', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '砍价最低价', 'slot' => 'min_price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 3) {
$header[] = ['title' => '拼团价', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 4) {
$header[] = ['title' => '兑换积分', 'slot' => 'price', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 6) {
$header[] = ['title' => '预售价', 'key' => 'price', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '划线价', 'key' => 'ot_price', 'align' => 'center', 'minWidth' => 80];
} else {
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '划线价', 'key' => 'ot_price', 'align' => 'center', 'minWidth' => 80];
}
$header[] = ['title' => '库存', 'key' => 'stock', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '限量', 'slot' => 'quota', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '重量(KG)', 'key' => 'weight', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '体积(m³)', 'key' => 'volume', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '商品编码', 'key' => 'bar_code', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '条形码', 'key' => 'bar_code_number', 'align' => 'center', 'minWidth' => 80];
return ['items' => $attr, 'attrs' => $valueNew, 'header' => $header];
}
/**
* getProductRules 回退方案
* 旧 SKU 表无数据时,从商品中台表(eb_product_center)读取基本信息,
* 构建一条"默认规格"供前端作为模板。
* @param int $id 商品中台 ID
* @param int $type 活动类型(同 getProductRules)
* @return array
*/
protected function getProductRulesFallback(int $id, int $type = 0): array
{
/** @var \app\dao\product\CenterProductDao $centerProductDao */
$centerProductDao = app()->make(\app\dao\product\CenterProductDao::class);
$product = $centerProductDao->get($id, ['id', 'price', 'stock', 'original_price as ot_price', 'image']);
if (!$product) {
return [];
}
// 构造单条"默认"规格的 attr 定义
$attr = [
[
'value' => '规格',
'detailValue' => '',
'attrHidden' => true,
'detail' => ['默认'],
],
];
// 构造默认 SKU 值
$valueNew = [
[
'value1' => '默认',
'detail' => ['规格' => '默认'],
'pic' => $product['image'] ?? '',
'price' => floatval($product['price'] ?? 0),
'cost' => 0.0,
'ot_price' => floatval($product['ot_price'] ?? 0),
'stock' => intval($product['stock'] ?? 0),
'quota' => intval($product['stock'] ?? 0),
'bar_code' => '',
'bar_code_number' => '',
'unique' => '',
'weight' => 0.0,
'volume' => 0.0,
'brokerage' => 0.0,
'brokerage_two' => 0.0,
'vip_price' => 0.0,
],
];
// 构造表头(与 getProductRules 保持一致)
$header = [
['title' => '规格', 'key' => 'value1', 'align' => 'center', 'minWidth' => 80],
];
$header[] = ['title' => '图片', 'slot' => 'pic', 'align' => 'center', 'minWidth' => 120];
if ($type == 1) {
$header[] = ['title' => '秒杀价', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 2) {
$header[] = ['title' => '砍价起始金额', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '砍价最低价', 'slot' => 'min_price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 3) {
$header[] = ['title' => '拼团价', 'slot' => 'price', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '日常售价', 'key' => 'r_price', 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 4) {
$header[] = ['title' => '兑换积分', 'slot' => 'price', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
} elseif ($type == 6) {
$header[] = ['title' => '预售价', 'key' => 'price', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '划线价', 'key' => 'ot_price', 'align' => 'center', 'minWidth' => 80];
} else {
$header[] = ['title' => '成本价', 'key' => 'cost', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '划线价', 'key' => 'ot_price', 'align' => 'center', 'minWidth' => 80];
}
$header[] = ['title' => '库存', 'key' => 'stock', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '限量', 'slot' => 'quota', 'type' => 1, 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '重量(KG)', 'key' => 'weight', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '体积(m³)', 'key' => 'volume', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '商品编码', 'key' => 'bar_code', 'align' => 'center', 'minWidth' => 80];
$header[] = ['title' => '条形码', 'key' => 'bar_code_number', 'align' => 'center', 'minWidth' => 80];
return ['items' => $attr, 'attrs' => $valueNew, 'header' => $header];
}
/**
* 更新实物商品 SKU 图片
* 商品采集复制时异步下载规格图片后写入 eb_pt_normal_sku
* @param int $skuId SKU 主键
* @param string $image 图片地址
* @return bool
*/
public function updateNormalSkuImage(int $skuId, string $image): bool
{
try {
//通过事件代理更新 SKU 图片,避免 app 层直接引用 modules
$results = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'updateImage', 'args' => [$skuId, $image]]);
return proxy_event_result($results, false);
} catch (\Throwable $e) {
return false;
}
}
/**
* 获取实物商品 SKU 列表
* 供移动管理端读取规格库存
* @param int $productId
* @return array
*/
public function getNormalProductSkuList(int $productId): array
{
try {
//通过事件代理查询商品启用的 SKU 列表,避免 app 层直接引用 modules
$results = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getShowListByProductId', 'args' => [$productId]]);
return proxy_event_result($results, []);
} catch (\Throwable $e) {
return [];
}
}
/**
* 批量更新实物商品 SKU 并同步中台主表
* 移动管理端修改规格价格/库存时调用
* @param int $productId
* @param array $attrValues 以 unique_code 为键的规格数据
* @return bool
*/
public function batchUpdateNormalProductSku(int $productId, array $attrValues): bool
{
try {
//通过事件代理查询 SKU 列表(以 unique_code 为键),避免 app 层直接引用 modules
$results = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getColumnByProductId', 'args' => [$productId, '*', 'unique_code']]);
$skuList = proxy_event_result($results, []);
} catch (\Throwable $e) {
return false;
}
$productStock = $productPrice = $productOtPrice = $productCost = 0;
foreach ($skuList as $unique => $sku) {
$attr = $attrValues[$unique] ?? [];
$update = [
'price' => isset($attr['price']) ? (float)$attr['price'] : (float)$sku['price'],
'stock' => isset($attr['stock']) ? (int)$attr['stock'] : (int)$sku['stock'],
'cost' => isset($attr['cost']) ? (float)$attr['cost'] : (float)$sku['cost'],
'ot_price' => isset($attr['ot_price']) ? (float)$attr['ot_price'] : (float)$sku['ot_price'],
];
if ($attr) {
//通过事件代理更新 SKU 数据,避免 app 层直接引用 modules
event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'update', 'args' => [(int)$sku['id'], $update]]);
}
$productStock = bcadd((string)$productStock, (string)$update['stock'], 0);
$productPrice = max($productPrice, $update['price']);
$productOtPrice = max($productOtPrice, $update['ot_price']);
$productCost = max($productCost, $update['cost']);
}
return $this->update($productId, [
'stock' => $productStock,
'price' => $productPrice,
'ot_price' => $productOtPrice,
'cost' => $productCost,
]) !== false;
}
/**
* 检查商品是否有活动
* @param $id
* @return bool
*/
public function checkActivity($id = 0, $return = false)
{
if ($id) {
// 通过活动注册中心检查商品是否参与营销类活动(排除普通购买等基础交易模式,避免误拦所有商品),
// 活动名称由各交易模式 Handler 的 getLabel() 动态提供,禁止硬编码模式映射表
$activityRegistry = app()->make(TradeModeRegistry::class);
$marketingKeys = array_flip($activityRegistry->getMarketingKeys());
$labels = array_column($activityRegistry->allInfo(), 'label', 'key');
$activities = $activityRegistry->checkProductActivities([$id]);
if (!empty($activities[$id])) {
foreach ($activities[$id] as $type => $exist) {
if ($exist && isset($marketingKeys[$type])) {
$typeName = $labels[$type] ?? $type;
if ($return) {
return false;
} else {
throw new AdminException("商品参与{$typeName}活动开启,无法进行此操作");
}
}
}
}
}
return true;
}
/**
* 保存
* @param array $data
* @return mixed
*/
public function create(array $data)
{
return $this->dao->save($data);
}
/**
* 前台获取商品列表
* @param array $where
* @param int $uid
* @return array|array[]
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getGoodsList(array $where, int $uid)
{
$where['is_show'] = 1;
$where['is_del'] = 0;
$where['star'] = 1;
$ifKeyword = isset($where['store_name']) && $where['store_name'];
if ($ifKeyword) {
app()->make(UserSearchServices::class)->saveUserSearch($uid, $where['store_name'], [$where['store_name']], []);
}
[$page, $limit] = $this->getPageValue();
$where['vip_user'] = $uid ? app()->make(UserServices::class)->value(['uid' => $uid], 'is_money_level') : 0;
$list = $this->dao->getSearchList($where, $page, $limit, ['id,name as store_name,cate_id,image,IFNULL(sales, 0) + IFNULL(virtual_sales, 0) as sales,price,stock,original_price as ot_price,spec_type,recommend_image,unit_name,is_vip,vip_price,presale,custom_form,product_type,min_qty,label_list']);
/** @var MemberCardServices $memberCardService */
$memberCardService = app()->make(MemberCardServices::class);
$vipStatus = $memberCardService->isOpenMemberCard('vip_price');
// 提取所有 label_list 并过滤空值
$labelLists = array_map(function ($item) {
return $item['label_list'];
}, $list);
// 将 label_list 转换为数组,过滤空值,并去重
$uniqueLabels = array_unique(array_reduce($labelLists, function ($carry, $labels) {
// 将 label_list 按逗号分割成数组
$labelsArray = explode(',', $labels);
// 过滤掉空值
$labelsArray = array_filter($labelsArray, function ($label) {
return !empty(trim($label));
});
return array_merge($carry, $labelsArray);
}, []));
$labelList = app()->make(StoreProductLabelServices::class)->labelListByIds($uniqueLabels);
// 使用 id 作为键
$labelList = array_column($labelList, null, 'id');
foreach ($list as &$item) {
if (!$this->vipIsOpen(!!$item['is_vip'], $vipStatus)) {
$item['vip_price'] = 0;
}
// 自定义表单商品不支持加入购物车(前端据此点击直接进详情)
$item['cart_button'] = $this->hasCustomForm($item['custom_form'] ?? '') || $this->getCartModeByType($item['product_type'] ?? 'normal') === 'direct' ? 0 : 1;
if (count($item['star'])) {
$item['star'] = bcdiv((string)array_sum(array_column($item['star'], 'product_score')), (string)count($item['star']), 1);
} else {
$item['star'] = '5.0';
}
$item['label_list'] = $item['label_list'] !== '' ? array_map(function ($labelId) use ($labelList) {
return $labelList[$labelId] ?? [];
}, explode(',', $item['label_list'])) : [];
}
$list = $this->getActivityList($list);
$list = $this->getProduceOtherList($list, $uid, !!$where['type']);
return $list;
}
/**
* 获取某些模板所需得购物车数量
* @param array $list
* @param int $uid
* @return array
*/
public function getProduceOtherList(array $list, int $uid, bool $type = true)
{
if (!$type || !$list) {
return $list;
}
$productIds = array_column($list, 'id');
if ($productIds) {
if ($uid) {
/** @var StoreCartServices $cartServices */
$cartServices = app()->make(StoreCartServices::class);
$cartNumList = $cartServices->productIdByCartNum($productIds, $uid);
$data = [];
foreach ($cartNumList as $item) {
$data[$item['product_id']][] = $item['cart_num'];
}
$newNumList = [];
foreach ($data as $key => $item) {
$newNumList[$key] = array_sum($item);
}
$cartNumList = $newNumList;
} else {
$cartNumList = [];
}
foreach ($list as &$item) {
// 多规格标记直接由中台表 spec_type 决定,不再查询 eb_store_product_attr_value
$item['is_att'] = !empty($item['spec_type']);
$item['cart_num'] = $cartNumList[$item['id']] ?? 0;
}
}
return $list;
}
/**
* 获取商品活动标签
* @param array $list
* @param array $productIds
* @return array
*/
public function getActivityList(array $list, bool $status = true, $seckillIdsList = false, $pinkIdsList = false, $bargrainIdsList = false)
{
if (!$list) return [];
if ($status) {
$productIds = array_column($list, 'id');
} else {
$productIds = [$list['id']];
$list = [$list];
}
// 通过活动注册中心获取活动ID列表
$activityRegistry = app()->make(TradeModeRegistry::class);
if ($seckillIdsList === false) {
$seckillIdsList = $activityRegistry->get('seckill')->getActivityIdsList($productIds, ['id', 'time_id', 'product_id']);
}
if ($pinkIdsList === false) {
$pinkIdsList = $activityRegistry->get('combination')->getActivityIdsList($productIds, ['id']);
}
if ($bargrainIdsList === false) {
$bargrainIdsList = $activityRegistry->get('bargain')->getActivityIdsList($productIds, ['id']);
}
/** @var StoreCouponIssueServices $couponIssueServices */
$couponIssueServices = app()->make(StoreCouponIssueServices::class);
foreach ($list as &$item) {
$seckillId = array_filter($seckillIdsList, function ($val) use ($item) {
if ($val['product_id'] === $item['id']) {
return $val;
}
});
$item['activity'] = $this->activity($item['activity'], $item['id'], $pinkIdsList[$item['id']] ?? 0, $seckillId, $bargrainIdsList[$item['id']] ?? 0, $status);
$item['checkCoupon'] = $couponIssueServices->checkProductCoupon($item['id']);
}
if ($status) {
return $list;
} else {
return $list[0]['activity'];
}
}
/**
* 获取商品在此时段活动优先类型
* @param string $activity
* @param int $id
* @param int $combinationId
* @param array $seckillId
* @param int $bargainId
* @param bool $status
* @return array
*/
public function activity(string $activity, int $id, int $combinationId, array $seckillId, int $bargainId, bool $status = true)
{
if (!$activity) {
$activity = '0,1,2,3'; //如果老商品没有活动顺序,默认活动顺序,秒杀-砍价-拼团
}
$activity = explode(',', $activity);
if ($activity[0] == 0 && $status) return [];
$activityId = [];
$time_id = 0;
if ($seckillId) {
foreach ($seckillId as $v) {
// 按 time_id 查询秒杀时间段数据(直接调 app 层服务,无需全局函数封装)
try {
$_ids = is_string($v['time_id']) ? explode(',', $v['time_id']) : $v['time_id'];
$_data = app()->make(SystemGroupDataServices::class)->getGroupDataColumn($_ids);
$timeInfo = is_object($_data) ? $_data->toArray() : $_data;
} catch (\Exception $e) {
$timeInfo = [];
}
foreach ($timeInfo as $time) {
$value = json_decode($time['value'], true);
if (date('H') >= $value['time']['value'] && date('H') < ($value['time']['value'] + $value['continued']['value'])) {
$activityId[1] = $v['id'];
$time_id = $time['id'];
break;
}
}
}
}
if ($bargainId) $activityId[2] = $bargainId;
if ($combinationId) $activityId[3] = $combinationId;
$data = [];
foreach ($activity as $k => $v) {
if (array_key_exists($v, $activityId)) {
if ($status) {
$data['type'] = $v;
$data['id'] = $activityId[$v];
if ($v == 1) $data['time_id'] = $time_id;
break;
} else {
if ($v != 0) {
$arr['type'] = $v;
$arr['id'] = $activityId[$v];
if ($v == 1) $arr['time_id'] = $time_id;
$data[] = $arr;
}
}
}
}
return $data;
}
/**
* 获取热门商品
* @param array $where
* @param int $num
* @return array|array[]
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getProducts(array $where, int $num = 0)
{
[$page, $limit] = $this->getPageValue();
if ($num) {
$page = 1;
$limit = $num;
}
$list = $this->dao->getSearchList($where, $page, $limit, ['id,name as store_name,cate_id,image,IFNULL(sales, 0) + IFNULL(virtual_sales, 0) as sales,price,stock,unit_name,presale,is_vip,vip_price']);
// eb_product_center 无 activity 列,补默认值兼容 getActivityList
foreach ($list as &$item) {
$item['activity'] = $item['activity'] ?? '';
}
$list = $this->getActivityList($list);
return $list;
}
/**
* 获取商品详情
* @param Request $request
* @param int $id
* @param int $type
* @return mixed
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function productDetail(Request $request, int $id, int $type)
{
$uid = (int)$request->uid();
$data['uid'] = $uid;
$storeInfo = $this->dao->getOne(['id' => $id, 'is_show' => 1, 'is_del' => 0], '*', ['description']);
if (!$storeInfo) {
throw new AdminException('商品不存在');
} else {
$storeInfo = $storeInfo->toArray();
}
$siteUrl = sys_config('site_url');
$storeInfo['image'] = set_file_url($storeInfo['image'], $siteUrl);
$storeInfo['image_base'] = set_file_url($storeInfo['image'], $siteUrl);
$storeInfo['video_link'] = empty($storeInfo['video_link']) ? '' : (strpos($storeInfo['video_link'], 'http') === false ? (sys_config('site_url') . $storeInfo['video_link']) : $storeInfo['video_link']);
$storeInfo['fsales'] = ($storeInfo['virtual_sales'] ?? 0) + $storeInfo['sales'];
$storeInfo['custom_form'] = json_decode($storeInfo['custom_form'] ?? '', true) ?? [];
$storeInfo['params_list'] = json_decode($storeInfo['params_list'] ?? '', true) ?? [];
if ($storeInfo['label_list'] !== '') {
$labelIds = explode(',', $storeInfo['label_list']);
$storeInfo['label_list'] = [];
$labelList = app()->make(StoreProductLabelServices::class)->labelListByIds($labelIds);
foreach ($labelIds as $labelId) {
foreach ($labelList as $label) {
if ($labelId == $label['id']) {
$storeInfo['label_list'][] = $label;
}
}
}
} else {
$storeInfo['label_list'] = [];
}
if ($storeInfo['protection_list'] !== '') {
$storeInfo['protection_list'] = app()->make(StoreProductProtectionServices::class)->protectionListByIds(explode(',', $storeInfo['protection_list']));
} else {
$storeInfo['protection_list'] = [];
}
$storeInfo['slider_image'] = set_file_url($storeInfo['slider_image'], $siteUrl);
if (sys_config('share_qrcode', 0) && request()->isWechat()) {
/** @var QrcodeServices $qrcodeService */
$qrcodeService = app()->make(QrcodeServices::class);
$storeInfo['wechat_code'] = $qrcodeService->getTemporaryQrcode('product-' . $id, $uid)->url;
} else {
$storeInfo['wechat_code'] = '';
}
/** @var StoreProductRelationServices $storeProductRelationServices */
$storeProductRelationServices = app()->make(StoreProductRelationServices::class);
$storeInfo['userCollect'] = $storeProductRelationServices->isProductRelation(['uid' => $uid, 'product_id' => $id, 'type' => 'collect', 'category' => 'product']);
$storeInfo['userLike'] = false;
$storeInfo['small_image'] = $storeInfo['image'];
$storeInfo = get_thumb_water($storeInfo, 'small', ['small_image']);
//预售相关
if ($storeInfo['presale']) {
if ($storeInfo['presale_start_time'] > time()) {
$storeInfo['presale_pay_status'] = 1;
} elseif ($storeInfo['presale_start_time'] <= time() && $storeInfo['presale_end_time'] > time()) {
$storeInfo['presale_pay_status'] = 2;
} elseif ($storeInfo['presale_end_time'] < time()) {
$storeInfo['presale_pay_status'] = 3;
} else {
$storeInfo['presale_pay_status'] = 0;
}
} else {
$storeInfo['presale_pay_status'] = 0;
}
$storeInfo['presale_start_time'] = date('Y-m-d H:i', $storeInfo['presale_start_time']);
$storeInfo['presale_end_time'] = date('Y-m-d H:i', $storeInfo['presale_end_time']);
// 已迁移到 ProductTypeHandler::getDetailSpecs,不再查询 eb_store_product_attr_result
$productType = $this->getProductType($id);
$handler = app()->make(ProductTypeRegistry::class)->get($productType);
// 自定义表单商品不支持加入购物车,强制 direct 单按钮模式
$cartStrategy = $handler->getCartStrategy();
if ($this->hasCustomForm($storeInfo['custom_form'] ?? [])) {
$cartStrategy['cart_mode'] = 'direct';
}
$storeInfo['cart_strategy'] = $cartStrategy;
// cart_button 由 cart_strategy.cart_mode 决定:direct 模式不展示加入购物车按钮
// 预售商品在模板中走独立分支,不影响此处判断
$storeInfo['cart_button'] = ($cartStrategy['cart_mode'] ?? 'cart') === 'direct' ? 0 : 1;
$specs = $handler->getDetailSpecs($id);
$productAttr = $specs['productAttr'] ?? [];
$productValue = $specs['productValue'] ?? [];
$attrPics = [];
foreach ($productAttr as $attrValues) {
foreach ($attrValues['attr_value'] as $picArr) {
if (isset($picArr['pic']) && $picArr['pic'] !== '') {
$attrPics[] = $picArr['pic'];
}
}
}
$storeInfo['attrPics'] = $attrPics;
$attrValue = $productValue;
if (!$storeInfo['spec_type']) {
$productAttr = [];
$productValue = [];
$data['spec_unique'] = $attrValue['默认']['unique'];
} else {
foreach ($productValue as &$sku) {
if ($sku['is_show'] == 0) {
$storeInfo['stock'] = $storeInfo['stock'] - $sku['stock'];
$sku['stock'] = 0;
}
}
$data['spec_unique'] = '';
}
$data['productAttr'] = $productAttr;
$data['productValue'] = $productValue;
$data['storeInfo'] = $storeInfo;
/** @var MemberCardServices $memberCardService */
$memberCardService = app()->make(MemberCardServices::class);
$data['svip_open'] = $vipStatus = $memberCardService->isOpenMemberCard('vip_price');
$data['svip_price_open'] = (int)sys_config('member_price_status');
$data['storeInfo']['svip_economize_price'] = bcsub((string)$data['storeInfo']['price'], (string)$data['storeInfo']['vip_price'], 2);
if (!$this->vipIsOpen(!!$storeInfo['is_vip'], $vipStatus)) {
$data['storeInfo']['vip_price'] = 0;
}
$data['priceName'] = 0;
if ($uid && (int)sys_config('brokerage_window_switch', 1)) {
$user = $request->user();
$agentProfile = event('DistributionServiceProxy', [
'service' => 'DistributionUserServices',
'action' => 'profile',
]);
$isPromoter = (int)($agentProfile[0]['is_promoter'] ?? $user->is_promoter);
if (!$isPromoter) {
/** @var OrderCenterDao $storeOrderService */
$storeOrderService = app()->make(OrderCenterDao::class);
$price = $storeOrderService->sum(['pay_status' => 1, 'refund_status' => 0, 'uid' => $uid], 'pay_price');
$status = is_brokerage_statu($price);
if ($status) {
$handled = event('DistributionServiceProxy', [
'service' => 'DistributionUserServices',
'action' => 'saveProfile',
'args' => [(int)$uid, ['is_promoter' => 1, 'spread_open' => 1]],
]);
if (!is_array($handled) || ($handled[0] ?? null) === null) {
app()->make(UserServices::class)->update($uid, ['is_promoter' => 1]);
}
}
}
$data['priceName'] = $this->getPacketPrice($storeInfo, $attrValue, $uid);
//用户访问事件
event('UserVisit', [$uid, $id, 'product', $storeInfo['cate_id'], 'view']);
}
/** @var StoreProductReplyServices $storeProductReplyService */
$storeProductReplyService = app()->make(StoreProductReplyServices::class);
$data['reply'] = get_thumb_water($storeProductReplyService->getRecProductReply($id), 'big', ['pics']);
[$replyCount, $goodReply, $replyChance] = $storeProductReplyService->getProductReplyData($id);
$data['replyChance'] = $replyChance;
$data['replyCount'] = $replyCount;
$data['mer_id'] = 0;
$vip_user = $uid ? app()->make(UserServices::class)->value(['uid' => $uid], 'is_money_level') : 0;
// 商品详情关联推荐仅使用 recommend_list,is_good 字段已删除
if ($storeInfo['recommend_list'] !== '') {
$recommend_list = explode(',', $storeInfo['recommend_list']);
$data['good_list'] = $this->getProducts(['ids' => $recommend_list, 'is_del' => 0, 'is_show' => 1], 12);
} else {
$data['good_list'] = [];
}
$data['mapKey'] = sys_config('tengxun_map_key');
$data['store_self_mention'] = (int)sys_config('store_self_mention') ?? 0; //门店自提是否开启
$data['activity'] = $this->getActivityList($data['storeInfo'], false);
/** @var StoreCouponIssueServices $couponService */
$couponService = app()->make(StoreCouponIssueServices::class);
$data['coupons'] = $couponService->getIssueCouponList($uid, ['product_id' => $id, 'type' => -1])['list'];
$data['routine_contact_type'] = sys_config('routine_contact_type', 0);
// 商品访问事件(处理访问记录)
event('Product', ['visit', [$id, $uid, date('Y-m-d H:i:s'), &$data]]);
return $data;
}
/**
* 是否开启vip
* @param bool $vip
* @return bool
*/
public function vipIsOpen(bool $vip = false, $vipStatus = -1)
{
if ($vipStatus == -1) {
/** @var MemberCardServices $memberCardService */
$memberCardService = app()->make(MemberCardServices::class);
$vipStatus = $memberCardService->isOpenMemberCard('vip_price');
}
return $vipStatus && sys_config('member_card_status') && $vip && sys_config('member_price_status', 1);
}
/**
* 获取商品分销佣金最低和最高
* @param $storeInfo
* @param $productValue
* @param int $uid
* @return int|string
*/
public function getPacketPrice($storeInfo, $productValue, int $uid)
{
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
if (!$userServices->checkUserPromoter($uid)) {
return 0;
}
if (!count($productValue)) {
return 0;
}
if (!sys_config('brokerage_func_status')) {
return 0;
}
// 未开启分销时不计算返佣
if (empty($storeInfo['is_sub'])) {
return 0;
}
$store_brokerage_ratio = sys_config('store_brokerage_ratio');
$store_brokerage_ratio = bcdiv((string)$store_brokerage_ratio, '100', 2);
$maxPrice = max(array_column($productValue, 'price'));
$minPrice = min(array_column($productValue, 'price'));
$maxPrice = bcmul((string)$store_brokerage_ratio, (string)$maxPrice, 0);
$minPrice = bcmul((string)$store_brokerage_ratio, (string)$minPrice, 0);
if ($minPrice == 0 && $maxPrice == 0) {
$priceName = 0;
} else if ($minPrice == 0 && $maxPrice)
$priceName = $maxPrice;
else if ($maxPrice == 0 && $minPrice)
$priceName = $minPrice;
else if ($maxPrice == $minPrice && $minPrice)
$priceName = $maxPrice;
else
$priceName = $minPrice . '~' . $maxPrice;
return strlen(trim($priceName)) ? $priceName : 0;
}
/**
* 设置会员价格
* @param $list
* @param int $uid
* @param $userInfo
* @param $vipStatus
* @param int $discount
* @param float $vipPrice
* @param bool $is_show
* @return array|float|int|mixed|string
*/
public function setLevelPriceV2($price, int $uid, $userInfo, $vipStatus, $discount = 0, $vipPrice = 0.00, $is_vip = 0, $is_show = false)
{
if ($uid) {
if (!$userInfo) {
/** @var UserServices $user */
$user = app()->make(UserServices::class);
$userInfo = $user->getUserInfo($uid);
}
if ($discount === 0) {
/** @var SystemUserLevelServices $systemLevel */
$systemLevel = app()->make(SystemUserLevelServices::class);
$discount = $systemLevel->value(['id' => $userInfo['level'], 'is_del' => 0, 'is_show' => 1], 'discount');
}
} else {
//没登录
$discount = 100;
$userInfo = [];
}
$discount = bcdiv((string)$discount, '100', 2);
if (!$vipStatus) $is_vip = 0;
//is_vip == 0表示会员价格不启用,展示为零
if ($is_vip == 0) $vipPrice = 0;
$noPayVipPrice = ($discount && sys_config('member_func_status')) ? bcmul((string)$discount, (string)$price, 2) : $price;
$vipPrice = ($vipPrice < $noPayVipPrice && $vipPrice > 0) ? $vipPrice : $noPayVipPrice;
//如果$isSingle==true 返回优惠后的总金额,否则返回优惠的金额
if ($vipStatus && $is_vip == 1 && (!$is_show || ($is_show && $userInfo && isset($userInfo['is_money_level']) && $userInfo['is_money_level'] > 0))) {
return [(float)$vipPrice, (float)bcsub((string)$price, (string)$vipPrice, 2)];
} else {
return [(float)$noPayVipPrice, (float)bcsub((string)$price, (string)$noPayVipPrice, 2)];
}
}
/**
* 设置会员价格
* @param $list
* @param int $uid
* @param $userInfo
* @param $vipStatus
* @param bool $isSingle
* @param int $discount
* @param float $vipPrice
* @param bool $is_show
* @return array|float|int|mixed|string
*/
public function setLevelPrice($price, int $uid, $userInfo, $vipStatus, $discount = 0, $vipPrice = 0.00, $is_vip = 0, $is_show = false)
{
if (!(float)$price) return [0, 0, 'level'];
if (!$vipStatus) $is_vip = 0;
//已登录
if ($uid) {
if (!$userInfo) {
/** @var UserServices $user */
$user = app()->make(UserServices::class);
$userInfo = $user->getUserInfo($uid);
}
if ($discount === 0) {
$discount = 100;
if (sys_config('member_func_status', 1)) {
/** @var SystemUserLevelServices $systemLevel */
$systemLevel = app()->make(SystemUserLevelServices::class);
$discount = $systemLevel->value(['id' => $userInfo['level'], 'is_del' => 0, 'is_show' => 1], 'discount') ?: 100;
}
}
} else {
//没登录
$discount = 100;
}
$discount = bcdiv((string)$discount, '100', 2);
//执行减去会员优惠金额
[$truePrice, $vip_truePrice, $type] = $this->isPayLevelPrice($uid, $userInfo, $vipStatus, $price, $discount, $vipPrice, $is_vip, $is_show);
//返回优惠后的总金额
$truePrice = $truePrice < 0.01 ? 0.01 : $truePrice;
//优惠的金额
$vip_truePrice = $vip_truePrice == $price ? bcsub((string)$vip_truePrice, '0.01', 2) : $vip_truePrice;
return [(float)$truePrice, (float)$vip_truePrice, $type];
}
/**商品列表
* @param array $where
* @param $limit
* @param $field
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getProductLimit(array $where, $limit, $field)
{
return $this->dao->getProductLimit($where, $limit, $field);
}
/**通过条件获取商品列表
* @param $where
* @param $field
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getProductListByWhere($where, $field)
{
return $this->dao->getProductListByWhere($where, $field);
}
/**
* 根据指定id获取商品列表
* @param array $ids
* @param string $field
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getProductColumn(array $ids, string $field = '')
{
$productData = [];
$productInfoField = 'id,image,price,original_price as ot_price,vip_price,postage,give_integral,sales,stock,store_name,unit_name,is_show,is_del,is_postage,cost,is_sub,is_vip,temp_id';
if (!empty($ids)) {
$productAll = $this->dao->idByProductList($ids, $field ?: $productInfoField);
if (!empty($productAll))
$productData = array_combine(array_column($productAll, 'id'), $productAll);
}
return $productData;
}
/**
* 商品是否存在
* @param int $productId
* @param string $field
* @return array|\think\Model|null
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author 吴汐
* @email 442384644@qq.com
* @date 2023/02/15
*/
public function isValidProduct(int $productId, string $field = '*')
{
return $this->dao->getOne(['id' => $productId, 'is_del' => 0, 'is_show' => 1], $field);
}
/**
* 获取商品库存
* @param int $productId
* @param string $uniqueId
* @return int|mixed
*/
public function getProductStock(int $productId, string $uniqueId = '')
{
// 空 unique 直接返回中台表总库存
if ($uniqueId === '') {
return (int)($this->dao->value(['id' => $productId], 'stock') ?: 0);
}
// 通过 ProductTypeHandler 获取对应 SKU 库存
try {
$typeKey = $this->getProductType($productId);
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
$handler = $registry->get($typeKey);
$skuInfo = $handler->getSkuInfo($productId, $uniqueId);
if (!empty($skuInfo)) {
return (int)($skuInfo['stock'] ?? 0);
}
} catch (\Throwable $e) {
// 异常时回退到中台表总库存
}
return (int)($this->dao->value(['id' => $productId], 'stock') ?: 0);
}
/**
* 减库存,加销量
* 已迁移到 ProductTypeHandler,不再操作 eb_store_product_attr_value
* @param $num
* @param $productId
* @param string $unique
* @return bool
*/
public function decProductStock(int $num, int $productId, string $unique = '')
{
$res = true;
if ($unique) {
$productType = $this->getProductType($productId);
$handler = app()->make(ProductTypeRegistry::class)->get($productType);
$res = $res && $handler->decStock($productId, $unique, $num);
}
return $res && $this->dao->decStockIncSales(['id' => $productId], $num);
}
/**
* 加销量减库存
* 已迁移到 ProductTypeHandler,不再操作 eb_store_product_attr_value
* @param int $num
* @param int $productId
* @param string $unique
* @return bool
*/
public function incProductStock(int $num, int $productId, string $unique = '')
{
$res = true;
if ($unique) {
$productType = $this->getProductType($productId);
$handler = app()->make(ProductTypeRegistry::class)->get($productType);
$res = $res && $handler->incStock($productId, $unique, $num);
}
$res = $res && $this->dao->incStockDecSales(['id' => $productId], $num);
return $res;
}
/**
* 库存预警发送消息
* @param int $productId
*/
public function workSendStock(int $productId)
{
$stock = $this->dao->value(['id' => $productId], 'stock');
$replenishment_num = sys_config('store_stock') ?? 0; //库存预警界限
if ($replenishment_num >= $stock) {
try {
\crmeb\services\workerman\ChannelService::instance()->send('STORE_STOCK', ['id' => $productId]);
} catch (\Exception $e) {
}
}
}
/**
* 获取首页推荐商品(多个)
* @param int $uid
* @param array $fields
* @param bool $is_num
* @param string $type
* @return array[]
*/
public function getRecommendProductArr(int $uid, array $fields, bool $is_num = true, string $type = 'mid')
{
// is_best/is_new/is_benefit/is_hot/is_good 推荐位字段已删除,首页推荐位功能下线
return [[], [], [], [], []];
}
/**
* 单个获取首页推荐商品
* @param int $uid
* @param $field
* @param int $num
* @param string $type
* @return array|array[]
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getRecommendProduct(int $uid, $field, int $num = 0, string $type = 'mid')
{
// is_hot/is_new/is_benefit/is_best/is_good 推荐位字段已删除,直接返回空数组
if (is_string($field) && in_array($field, ['is_hot', 'is_new', 'is_benefit', 'is_best', 'is_good'], true)) {
return [];
}
// 会员商品推荐保留(is_vip 字段未删除)
$vip_user = $uid ? app()->make(UserServices::class)->value(['uid' => $uid], 'is_money_level') : 0;
$where = ['is_show' => 1, 'is_del' => 0, 'vip_user' => $vip_user];
if (is_array($field)) {
$where = array_merge($where, $field);
} elseif ($field === 'is_vip') {
$where['is_vip'] = 1;
}
$list = $this->dao->getRecommendProduct($where, is_string($field) ? $field : '', $num, 0, 0);
foreach ($list as &$item) {
$item['image'] = set_file_url($item['image']);
}
return $list;
}
/**
* 商品名称 图片
* @param array $productIds
* @return array
*/
public function getProductArray(array $where, string $field, string $key)
{
return $this->dao->getColumn($where, $field, $key);
}
/**
* 获取商品详情
* @param int $productId
* @param string $field
* @param array $with
* @return array|\think\Model|null
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getProductInfo(int $productId, string $field = '*', array $with = [])
{
return $this->dao->getOne(['is_del' => 0, 'is_show' => 1, 'id' => $productId], $field, $with);
}
/** 生成商品复制口令关键字
* @param int $productId
* @return string
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getProductWords(int $productId)
{
$productInfo = $this->dao->getOne(['is_del' => 0, 'is_show' => 1, 'id' => $productId]);
$keyWords = "";
if ($productInfo) {
$oneKey = "crmeb-fu致文本 Http:/ZБ";
$twoKey = "Б轉移至☞" . sys_config('site_name') . "☜";
$threeKey = "【" . $productInfo['store_name'] . "】";
$mainKey = base64_encode($productId);
$keyWords = $oneKey . $mainKey . $twoKey . $threeKey;
}
return $keyWords;
}
/**
* 获取会员价格(付费会员价格和购买商品会员价格)
* @param int $uid
* @param $userInfo
* @param $vipStatus
* @param $goodsList
* @param string $discount
* @param bool $isSingle
* @param float $payVipPrice
* @param bool $is_show
* @return float|int|mixed|string
*/
public function isPayLevelPrice(int $uid, $userInfo, $vipStatus, $price, string $discount, $payVipPrice = 0.00, $is_vip = 0, $is_show = false)
{
//is_vip == 0表示会员价格不启用,展示为零
if ($is_vip == 0) $payVipPrice = 0;
if (!$userInfo && $uid) {
//检测用户是否是付费会员
/** @var UserServices $userService */
$userService = app()->make(UserServices::class);
$userInfo = $userService->getUserInfo($uid);
}
$noPayVipPrice = ($discount && $discount != 0.00) ? bcmul((string)$discount, (string)$price, 2) : $price;
if ($payVipPrice < $noPayVipPrice && $payVipPrice > 0) {
$vipPrice = $payVipPrice;
$type = 'member';
} else {
$vipPrice = $noPayVipPrice;
$type = 'level';
}
//如果$isSingle==true 返回优惠后的总金额,否则返回优惠的金额
if ($vipStatus && $is_vip == 1) {
//$is_show == false 是计算支付价格,true是展示
if (!$is_show) {
return [$vipPrice, bcsub((string)$price, (string)$vipPrice, 2), $type];
} else {
if ($userInfo && isset($userInfo['is_money_level']) && $userInfo['is_money_level'] > 0) {
return [$vipPrice, bcsub((string)$price, (string)$vipPrice, 2), $type];
} else {
return [$noPayVipPrice, bcsub((string)$price, (string)$noPayVipPrice, 2), $type];
}
}
} else {
return [$noPayVipPrice, bcsub((string)$price, (string)$noPayVipPrice, 2), $type];
}
}
/**
* 通过商品id获取商品分类
* @param array $productId
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function productIdByProductCateName(array $productId)
{
$data = $this->dao->productIdByCateId($productId);
$cateData = [];
foreach ($data as $item) {
$cateData[$item['id']] = implode(',', array_map(function ($i) {
return $i['cate_name'];
}, $item['cateName']));
}
return $cateData;
}
/**
* 获取预售列表
* @param $where
* @return mixed
*/
public function getAdvanceList($where)
{
[$page, $limit] = $this->getPageValue();
$data = $this->dao->getAdvanceList($where, $page, $limit);
foreach ($data['list'] as &$item) {
}
return $data;
}
/**
* 商品分享二维码 推广员
* @param int $id
* @param string $userType
* @param $user
* @return mixed|string
*/
public function getCode(int $id, string $userType, $user)
{
if (!$id || !$this->isValidProduct($id, 'id')) {
throw new ApiException('商品不存在');
}
if ($userType == 'routine') {
/** @var QrcodeServices $qrcodeService */
$qrcodeService = app()->make(QrcodeServices::class);
$url = $qrcodeService->getRoutineQrcodePath($id, $user['uid'], 0, ['is_promoter' => $user['is_promoter']]);
if (!$url) {
throw new ApiException('二维码生成失败');
} else {
if ($url == 'unpublished') throw new ApiException('小程序尚未发布,无法生成商品海报');
return $url;
}
}
return '';
}
/**
* 商品批量设置
* @param $data
* @return bool
* @throws \Exception
*/
public function batchSetting($data)
{
$ids = $data['ids'];
$batchData = [];
if (!count($ids)) throw new AdminException('请选择商品');
switch ($data['type']) {
case 1: // 修改分类
$cate_id = $data['cate_id'];
if (!count($cate_id)) throw new AdminException('请选择分类');
/** @var StoreCategoryServices $storeCategoryServices */
$storeCategoryServices = app()->make(StoreCategoryServices::class);
$cateGory = $storeCategoryServices->getColumn([['id', 'IN', $cate_id]], 'id,pid', 'id');
if (!$cateGory) throw new AdminException('分类不存在');
$time = time();
$cateData = [];
foreach ($cate_id as $cid) {
if ($cid && isset($cateGory[$cid]['pid'])) {
foreach ($ids as $product_id) {
$cateData[$product_id][] = ['product_id' => $product_id, 'cate_id' => $cid, 'cate_pid' => $cateGory[$cid]['pid'], 'status' => 1, 'add_time' => $time];
}
}
}
/** @var StoreProductCateServices $storeProductCateServices */
$storeProductCateServices = app()->make(StoreProductCateServices::class);
foreach ($ids as $product_id) {
$storeProductCateServices->change($product_id, $cateData[$product_id]);
$this->dao->update($product_id, ['cate_id' => implode(',', $cate_id)]);
}
break;
case 2:
foreach ($ids as $product_id) {
// 仅实物商品批量修改物流信息
if (!$this->isVirtualProduct($this->getProductType($product_id))) {
// 通过事件机制解耦 app/ 层对 modules/ 层扩展服务的直接引用
event('ProductBatchLogistics', [$product_id, $data]);
}
}
break;
case 3:
foreach ($ids as $product_id) {
$batchData[] = [
'id' => $product_id,
'give_integral' => $data['give_integral']
];
}
if (count($batchData)) $this->dao->saveAll($batchData);
break;
case 4:
/** @var StoreProductCouponServices $storeProductCouponServices */
$storeProductCouponServices = app()->make(StoreProductCouponServices::class);
foreach ($ids as $product_id) {
if (!empty($data['coupon_ids'])) {
$storeProductCouponServices->setCoupon($product_id, $data['coupon_ids']);
} else {
$storeProductCouponServices->delete(['product_id' => $product_id]);
}
}
break;
case 5:
foreach ($ids as $product_id) {
$batchData[] = [
'id' => $product_id,
// 中台表使用 label_list 字段存储标签ID
'label_list' => implode(',', $data['label_id'])
];
}
if (count($batchData)) $this->dao->saveAll($batchData);
break;
case 9:
foreach ($ids as $product_id) {
$batchData[] = [
'id' => $product_id,
'label_list' => implode(',', $data['label_list'])
];
}
if (count($batchData)) $this->dao->saveAll($batchData);
break;
case 10:
$productTypeInfo = $this->dao->getColumn(['id' => $ids], 'product_type', 'id');
foreach ($ids as $product_id) {
// 仅实物商品批量修改赠品信息
if (!$this->isVirtualProduct($productTypeInfo[$product_id] ?? 'normal')) {
$batchData[] = [
'id' => $product_id,
'is_gift' => $data['is_gift'],
'gift_price' => $data['gift_price'],
];
}
}
if (count($batchData)) $this->dao->saveAll($batchData);
break;
default:
return true;
}
return true;
}
/**
* 商品迁移导出
* @param $where
* @return array
* @throws \ReflectionException
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author wuhaotian
* @email 442384644@qq.com
* @date 2024/10/9
*/
public function productExportList($where)
{
[$page, $limit] = $this->getPageValue();
$cateIds = [];
if (isset($where['cate_id']) && $where['cate_id']) {
/** @var StoreCategoryServices $storeCategory */
$storeCategory = app()->make(StoreCategoryServices::class);
$cateIds = $storeCategory->getColumn(['pid' => $where['cate_id']], 'id');
}
if ($cateIds) {
$cateIds[] = $where['cate_id'];
$where['cate_id'] = $cateIds;
}
$productList = $this->dao->getList($where, $page, $limit);
$header = [
'商品编号',
'商品名称',
'商品类型',
'商品分类(一级)',
'商品分类(二级)',
'商品单位',
'商品图片',
'商品视频',
'商品详情',
'已售数量',
'起购数量',
'规格类型',
'规格类型值',
'规格名称',
'规格值组合',
'规格图片',
'售价',
'划线价',
'成本价',
'库存',
'重量',
'体积',
'商品编码',
'条形码',
'商品简介',
'商品关键字',
'商品口令',
'购买送积分'
];
$filename = '商品迁移数据_' . date('YmdHis', time());
$export = $fileKey = [];
if (!empty($productList)) {
$productList = array_column($productList, null, 'id');
$productIds = array_column($productList, 'id');
$descriptionArr = app()->make(StoreDescriptionServices::class)->getColumn([['product_id', 'in', $productIds], ['type', '=', 0]], 'description', 'product_id');
$cateIds = implode(',', array_column($productList, 'cate_id'));
/** @var StoreCategoryServices $categoryService */
$categoryService = app()->make(StoreCategoryServices::class);
$cateList = $categoryService->getCateParentAndChildName($cateIds);
/** @var ProductTypeRegistry $registry */
$registry = app()->make(ProductTypeRegistry::class);
$i = 0;
foreach ($productList as $product_id => $productInfo) {
// 通过产品类型 Handler 获取规格数据,不再查询已弃用的 eb_store_product_attr_result
$handler = $registry->get($productInfo['product_type'] ?? 'normal');
$specs = $handler->getDetailSpecs((int)$product_id);
$productAttr = $specs['productAttr'] ?? [];
$productValue = $specs['productValue'] ?? [];
$cateName = array_filter($cateList, function ($val) use ($productInfo) {
if (in_array($val['id'], explode(',', $productInfo['cate_id']))) {
return $val;
}
});
if (count($cateName) == 1 && is_null($cateName[0]['one'])) {
$cateName = [['one' => $cateName[0]['two'], 'two' => '', 'id' => $cateName[0]['id']]];
}
// 构造规格类型值字符串
$attrArr = [];
foreach ($productAttr as $attrItem) {
$attrValues = $attrItem['attr_values'] ?? [];
$attrArr[] = ($attrItem['attr_name'] ?? '') . '=' . implode(',', $attrValues);
}
$attrString = implode(';', $attrArr);
foreach ($productValue as $unique => $value) {
$detail = $value['detail'] ?? ($value['suk'] ? array_map('trim', explode(',', $value['suk'])) : ['默认']);
$skuArr = [];
foreach ($productAttr as $index => $attrItem) {
$attrName = $attrItem['attr_name'] ?? ('规格' . ($index + 1));
$skuArr[$attrName] = $detail[$index] ?? '';
}
$one_data = [
'id' => intval($product_id),
'store_name' => $productInfo['name'],
'product_type' => $productInfo['product_type'] ?? 'normal',
'type_name' => $this->getProductTypeName($productInfo['product_type'] ?? 'normal'),
'cate_name_one' => reset($cateName)['one'] ?? '',
'cate_name_two' => reset($cateName)['two'] ?? '',
'unit_name' => $productInfo['unit_name'],
'slider_image' => implode(';', $productInfo['slider_image']),
'video_link' => $productInfo['video_link'],
'description' => htmlspecialchars_decode($descriptionArr[$productInfo['id']] ?? ''),
'ficti' => intval($productInfo['virtual_sales'] ?? 0),
'min_qty' => intval($productInfo['min_qty'] ?? 1),
'spec_type' => intval($productInfo['spec_type']) == 1 ? '多规格' : '单规格',
'sku_type_value' => $attrString,
'sku_name' => implode(',', $detail),
'sku_value' => implode(';', array_map(function ($key, $value) {
return "$key=$value";
}, array_keys($skuArr), $skuArr)),
'pic' => $value['image'] ?? '',
'price' => floatval($value['price'] ?? 0),
'ot_price' => floatval($value['ot_price'] ?? 0),
'cost' => floatval($value['cost'] ?? 0),
'stock' => intval($value['stock'] ?? 0),
'volume' => intval($value['volume'] ?? 0),
'weight' => intval($value['weight'] ?? 0),
'bar_code' => $value['bar_code'] ?? '',
'bar_code_number' => $value['bar_code_number'] ?? '',
'store_info' => $productInfo['intro'],
'keyword' => $productInfo['keyword'],
'command_word' => '',
'give_integral' => $productInfo['give_integral'],
];
$export[] = $one_data;
if ($i == 0) {
$fileKey = array_keys($one_data);
}
$i++;
}
}
}
return compact('header', 'fileKey', 'export', 'filename');
}
public function productImport($file)
{
$all = $success = $fail = 0;
$file = public_path() . substr($file, 1);
// 获取文件后缀
$suffix = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($suffix, ['xls', 'xlsx'])) {
return app('json')->fail('文件格式不正确,请上传xls或xlsx格式的文件!');
}
$importData = app()->make(File::class)->readExcel($file, 'product', 1, ucfirst($suffix));
if (!$importData) {
throw new AdminException('导入数据为空');
}
$productCateServices = app()->make(StoreCategoryServices::class);
$productData = $issetProductArr = [];
// 已迁移到实物商品独立 SKU 表 eb_pt_normal_sku 校验条形码唯一性
//通过事件代理查询 SKU 条形码集合,避免 app 层直接引用 modules
$barCodeResults = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getColumn', 'args' => [[], 'bar_code', 'id']]);
$barCodeArr = array_unique((is_array($barCodeResults) && $barCodeResults[0] !== null) ? $barCodeResults[0] : []);
$barCodeNumberResults = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getColumn', 'args' => [[], 'bar_code_number', 'id']]);
$barCodeNumberArr = array_unique((is_array($barCodeNumberResults) && $barCodeNumberResults[0] !== null) ? $barCodeNumberResults[0] : []);
foreach ($importData as $sku) {
if ($sku['id'] == null) continue;
if (!isset($productData[$sku['id']])) {
$productData[$sku['id']]['disk_info'] = '';
$productData[$sku['id']]['logistics'] = [1, 2];
$productData[$sku['id']]['freight'] = 2;
$productData[$sku['id']]['postage'] = 0;
$productData[$sku['id']]['recommend'] = [];
$productData[$sku['id']]['presale'] = 0;
$productData[$sku['id']]['is_limit'] = 0;
$productData[$sku['id']]['limit_type'] = 0;
$productData[$sku['id']]['limit_num'] = 0;
$productData[$sku['id']]['video_open'] = $sku['video_link'] !== '' ? 1 : 0;
$productData[$sku['id']]['vip_product'] = 0;
$productData[$sku['id']]['custom_form'] = [];
$productData[$sku['id']]['store_name'] = $sku['store_name'];
$productData[$sku['id']]['cate_id'] = $productCateServices->getCateId($sku['cate_name_one'] ?? '', $sku['cate_name_two'] ?? '');
$productData[$sku['id']]['keyword'] = $sku['keyword'] ?? '';
$productData[$sku['id']]['unit_name'] = $sku['unit_name'] ?? '';
$productData[$sku['id']]['store_info'] = $sku['store_info'] ?? '';
$productData[$sku['id']]['image'] = '';
$productData[$sku['id']]['recommend_image'] = '';
$productData[$sku['id']]['slider_image'] = explode(';', $sku['slider_image']);
$productData[$sku['id']]['description'] = $sku['description'] ?? '';
$productData[$sku['id']]['ficti'] = $sku['ficti'] ?? 0;
$productData[$sku['id']]['give_integral'] = $sku['give_integral'] ?? 0;
$productData[$sku['id']]['sort'] = 0;
$productData[$sku['id']]['is_show'] = 0;
$productData[$sku['id']]['is_postage'] = 0;
$productData[$sku['id']]['is_sub'] = 0;
$productData[$sku['id']]['is_vip'] = 0;
$productData[$sku['id']]['recommend_list'] = [];
$productData[$sku['id']]['product_type'] = $sku['product_type'] ?? 'normal';
$productData[$sku['id']]['spec_type'] = $sku['spec_type'] == '多规格' ? 1 : 0;
$productData[$sku['id']]['is_virtual'] = 0;
$productData[$sku['id']]['video_link'] = is_null($sku['video_link']) ? '' : $sku['video_link'];
$productData[$sku['id']]['temp_id'] = '';
$productData[$sku['id']]['activity'] = ['默认', '秒杀', '砍价', '拼团'];
$productData[$sku['id']]['couponName'] = [];
$productData[$sku['id']]['coupon_ids'] = [];
$productData[$sku['id']]['command_word'] = $sku['command_word'] ?? '';
$productData[$sku['id']]['min_qty'] = $sku['min_qty'];
$productData[$sku['id']]['type'] = 0;
$productData[$sku['id']]['is_copy'] = 0;
$productData[$sku['id']]['label_id'] = [];
$productData[$sku['id']]['params_list'] = [];
$productData[$sku['id']]['label_list'] = [];
$productData[$sku['id']]['protection_list'] = [];
}
$detail = [];
foreach (explode(';', $sku['sku_value']) as $pair) {
list($key, $value) = explode('=', $pair);
$detail[$key] = $value;
}
if ($sku['bar_code'] !== '' && in_array($sku['bar_code'], $barCodeArr)) {
$issetProductArr[] = $sku['id'];
}
if ($sku['bar_code_number'] !== '' && in_array($sku['bar_code_number'], $barCodeNumberArr)) {
$issetProductArr[] = $sku['id'];
}
$productData[$sku['id']]['attrs'][] = [
'attr_arr' => explode(',', $sku['sku_name']),
'detail' => $detail,
'price' => $sku['price'],
'pic' => $sku['pic'],
'ot_price' => $sku['ot_price'] ?? 0,
'cost' => $sku['cost'],
'stock' => $sku['stock'],
'is_show' => 1,
'is_default_select' => 0,
'is_virtual' => 0,
'brokerage' => 0,
'brokerage_two' => 0,
'vip_price' => 0,
'vip_proportion' => 0,
'unique' => '',
'weight' => $sku['weight'],
'volume' => $sku['volume'],
'bar_code' => $sku['bar_code'],
'bar_code_number' => $sku['bar_code_number'],
];
$items = [];
$pairs = explode(';', $sku['sku_type_value']);
foreach ($pairs as $pair) {
// 将每个部分按等号分割成 key 和 value
list($key, $values) = explode('=', $pair);
// 将 value 部分按逗号分割为数组
$detailArray = explode(',', $values);
$detail = [];
foreach ($detailArray as &$det) {
$detail[] = [
'value' => $det,
'pic' => '',
];
}
// 重新构建原始数组的结构
$items[] = [
'value' => $key,
'detail' => $detail
];
}
$productData[$sku['id']]['items'] = $items;
}
$all = count($productData);
foreach (array_unique($issetProductArr) as $issetProduct) {
if (isset($productData[$issetProduct])) {
unset($productData[$issetProduct]);
}
}
$success = count($productData);
$jump = $all - $success;
foreach ($productData as $info) {
$this->save(0, $info);
}
$fail = 0;
return compact('all', 'success', 'jump', 'fail');
}
public function otherInfo($id, $type)
{
$storeInfo = $this->dao->get($id, ['id', 'is_sub', 'is_vip', 'vip_product']);
if (!$storeInfo) throw new AdminException('商品不存在');
$storeInfo = $storeInfo->toArray();
$storeInfo['store_brokerage_ratio'] = sys_config('store_brokerage_ratio');
$storeInfo['store_brokerage_two'] = sys_config('store_brokerage_two');
// 已迁移到实物商品独立 SKU 表 eb_pt_normal_sku
//通过事件代理查询商品启用的 SKU 列表,避免 app 层直接引用 modules
$attrValueResults = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getShowListByProductId', 'args' => [(int)$id]]);
$attrValue = (is_array($attrValueResults) && $attrValueResults[0] !== null) ? $attrValueResults[0] : [];
// SKU 图片在库中为相对路径(/uploads/...),返回前补全站点域名,避免后台规格图裂图
$siteUrl = sys_config('site_url');
foreach ($attrValue as &$attrItem) {
foreach (['image', 'pic'] as $imageField) {
if (!empty($attrItem[$imageField]) && is_string($attrItem[$imageField])) {
$attrItem[$imageField] = set_file_url($attrItem[$imageField], $siteUrl);
}
}
}
unset($attrItem);
return compact('storeInfo', 'attrValue');
}
public function otherSave($id, $type, $data)
{
$upProductData = [];
if ($type == 2) {
$upProductData = [
'is_sub' => intval($data['is_sub'] ?? 0),
'is_vip' => intval($data['is_vip'] ?? 0),
'vip_product' => $data['vip_product'],
];
}
if ($upProductData) $this->dao->update($id, $upProductData);
// 已迁移到实物商品独立 SKU 表 eb_pt_normal_sku,不再维护 eb_store_product_attr_result
foreach ($data['attr_value'] as $item) {
$upAttrData = [];
if ($type == 2) {
// 未开启会员价时清空 SKU 会员价字段
$upAttrData = [
'vip_price' => !empty($data['is_vip']) ? ($item['vip_price'] ?? 0) : 0
];
}
if ($upAttrData && !empty($item['id'])) {
//通过事件代理更新 SKU 会员价,避免 app 层直接引用 modules
event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'update', 'args' => [(int)$item['id'], $upAttrData]]);
}
}
return true;
}
/**
* 真实价格
* @param $uid
* @param $id
* @param $unique
* @return array
* @author wuhaotian
* @email 442384644@qq.com
* @date 2025/2/5
*/
public function realPrice($uid, $id, $unique)
{
$isMember = $isVip = $levelPrice = $memberPrice = $realPrice = $price = 0;
$levelDiscount = 100;
if ($uid) {
$isMember = app()->make(UserServices::class)->value(['uid' => $uid], 'is_money_level');
if (sys_config('member_func_status', 0)) {
$levelDiscount = app()->make(UserLevelServices::class)->getUerLevelInfoByUid($uid, 'discount');
}
}
$productIsVip = $this->dao->value(['id' => $id], 'is_vip');
// 已迁移到 ProductTypeHandler::getSkuInfo,不再查询 eb_store_product_attr_value
$productType = $this->getProductType((int)$id);
$handler = app()->make(ProductTypeRegistry::class)->get($productType);
$attrInfo = $handler->getSkuInfo((int)$id, (string)$unique);
if (empty($attrInfo)) {
return ['real_price' => 0, 'price' => 0, 'is_vip' => 0, 'product_is_vip' => $productIsVip, 'member_price' => 0, 'level_price' => 0, 'user_is_member' => $isMember, 'ot_price' => 0];
}
$realPrice = $price = $attrInfo['price'];
$levelPrice = bcmul($attrInfo['price'], bcdiv($levelDiscount, 100, 2), 2);
$memberPrice = $attrInfo['vip_price'] ?? 0;
$otPrice = $attrInfo['ot_price'] ?? 0;
if ($levelDiscount && $isMember && $productIsVip) {
$realPrice = min($levelPrice, $memberPrice);
$isVip = $memberPrice < $levelPrice ? 1 : 0;
}
if ($levelDiscount && (!$isMember || !$productIsVip)) {
$realPrice = $levelPrice;
$isVip = 0;
}
if (!$levelDiscount && $isMember && $productIsVip) {
$realPrice = $memberPrice;
$isVip = 1;
}
/** @var CenterProductServices $storeProductService */
$storeProductService = app()->make(CenterProductServices::class);
/** @var StoreCategoryServices $storeCategoryService */
$storeCategoryService = app()->make(StoreCategoryServices::class);
$cateId = $storeProductService->value(['id' => $id], 'cate_id');
$cateId = explode(',', (string)$cateId);
$cateId = array_merge($cateId, $storeCategoryService->cateIdByPid($cateId));
$cateId = array_diff($cateId, [0]);
$list = app()->make(StoreCouponIssueServices::class)->getPcIssueCouponList($uid, $cateId, $id);
usort($list, function ($a, $b) {
return $b['coupon_price'] - $a['coupon_price'];
});
$time = time();
foreach ($list as $item) {
// 优惠券不在使用时间范围内
if ($item['start_use_time'] != 0 && ($item['start_use_time'] > $time || $item['end_use_time'] < $time)) {
continue;
}
// 用户未登录或者不是付费会员跳过付费会员券
if ($item['receive_type'] == 4 && !$isMember) {
continue;
}
// 判断用户是否还能领取或者已经领取未使用
if ($uid) {
$canUserCoupon = app()->make(StoreCouponUserServices::class)->getUserCouponCanUse($uid, $item['id'], $item['receive_limit']);
if (!$canUserCoupon) {
continue;
}
}
// 满足优惠券使用门槛
if ($realPrice >= $item['use_min_price']) {
$realPrice = bcsub($realPrice, $item['coupon_price'], 2);
if ($realPrice < 0) $realPrice = 0;
break;
}
}
return ['real_price' => $realPrice, 'price' => $price, 'is_vip' => $isVip, 'product_is_vip' => $productIsVip, 'member_price' => $memberPrice, 'level_price' => $levelPrice, 'user_is_member' => $isMember, 'ot_price' => $otPrice];
}
/**
* 批量移动回收站
* @param $ids
* @return bool
* @author wuhaotian
* @email 442384644@qq.com
* @date 2025/6/18
*/
public function batchDelete($ids)
{
$passNum = 0;
foreach ($ids as $id) {
//删除商品检测是否有参与活动
$res = $this->checkActivity($id, true);
if ($res) {
$data['is_del'] = 1;
$data['is_show'] = 0;
$this->dao->update($id, $data);
app()->make(StoreProductCateServices::class)->update(['product_id' => $id], ['status' => 0]);
app()->make(StoreCartServices::class)->changeStatus($id, 0);
} else {
$passNum++;
}
}
return $passNum ? $passNum . '件商品参与活动无法移到回收站,其他商品移动回收站成功' : '移动回收站成功';
}
public function batchRecover($ids)
{
foreach ($ids as $id) {
$data['is_del'] = 0;
$this->dao->update($id, $data);
app()->make(StoreProductCateServices::class)->update(['product_id' => $id], ['status' => 1]);
}
return true;
}
/**
* 自定义组件-商品
* @param $where
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author wuhaotian
* @email 442384644@qq.com
* @date 2026/1/13
*/
public function getThemeProduct($where)
{
$sort = $where['sort'] ? 'asc' : 'desc';
switch ($where['order']) {
case 0:
$order = 'sales ' . $sort;
break;
case 1:
$order = 'price ' . $sort;
break;
default:
$order = 'sort desc';
break;
}
$limit = empty($where['ids']) ? (int)$where['limit'] : 1000;
$list = $this->dao->getThemeProduct($where, $order, $limit);
foreach ($list as &$item) {
$item['cate_name'] = $item['cateName'][0]['cate_name'];
unset($item['cateName']);
}
if (empty($where['ids'])) return $list;
// 将$list转换为以id为键的数组
$list = array_column($list, null, 'id');
$data = [];
// 遍历where中的ids,按顺序取出对应商品
foreach (explode(',', $where['ids']) as $id) {
if (isset($list[$id])) {
$data[] = $list[$id];
}
}
return $data;
}
/**
* 清除回收站商品
* @param int $id 商品ID,0表示清除所有回收站商品
* @return bool
*/
public function recycleProduct(int $id = 0): bool
{
if ($id) {
$ids = [$id];
} else {
$ids = $this->getColumn(['is_del' => 1], 'id');
}
if (empty($ids)) {
return true;
}
// 触发产品类型删除回调,由各自 Handler 清理扩展表/SKU 数据
// 不再直接操作已弃用的 eb_store_product_attr* 表
/** @var ProductTypeRegistry $registry */
$registry = app()->make(ProductTypeRegistry::class);
foreach ($ids as $productId) {
try {
$productType = $this->getProductType((int)$productId);
$registry->get($productType)->afterProductDelete((int)$productId);
} catch (\Throwable $e) {
// 单个商品删除回调异常不影响整体流程
}
}
// 删除商品详情
/** @var StoreDescriptionServices $productDescription */
$productDescription = app()->make(StoreDescriptionServices::class);
$productDescription->delete([['product_id', 'in', $ids], ['type', '=', '0']]);
// 删除商品关联分类数据
/** @var StoreProductCateServices $productCate */
$productCate = app()->make(StoreProductCateServices::class);
$productCate->delete([['product_id', 'in', $ids]]);
// 删除商品关联优惠券数据
/** @var StoreProductCouponServices $productCoupon */
$productCoupon = app()->make(StoreProductCouponServices::class);
$productCoupon->delete([['product_id', 'in', $ids]]);
// 删除商品收藏记录
/** @var StoreProductReplyServices $productRelation */
$productRelation = app()->make(StoreProductReplyServices::class);
$productRelation->delete([['product_id', 'in', $ids], ['reply_type', '=', 'product']]);
// 删除商品的评论
/** @var StoreProductReplyServices $productReply */
$productReply = app()->make(StoreProductReplyServices::class);
$productReply->delete([['product_id', 'in', $ids]]);
// 删除商品
if ($id) {
$this->delete($id);
} else {
$this->delete(1, 'is_del');
}
return true;
}
/**
* 用户端商品列表
* 查询 eb_product_center 中 is_show=1, is_del=0 的商品
* 字段映射:中台字段名 → 前端期望字段名
* @param array $where
* @param int $page
* @param int $limit
* @return array
*/
public function getUserList(array $where, int $page, int $limit): array
{
$where['is_show'] = 1;
$where['is_del'] = 0;
// 查询字段:中台列 → 前端需的字段
$field = [
'id',
'name as store_name',
'cate_id',
'image',
'IFNULL(sales, 0) + IFNULL(virtual_sales, 0) as sales',
'price',
'stock',
'original_price as ot_price',
'spec_type',
'image as recommend_image',
'unit_name',
'product_type',
'sort_order',
'is_vip',
'vip_price',
'custom_form',
];
$list = $this->dao->getSearchList($where, $page, $limit, $field);
// 为每项补充前端需要但中台表没有的字段(默认值)
foreach ($list as &$item) {
$item['activity'] = '';
$item['vip_price'] = '0.00';
$item['is_virtual'] = $this->isVirtualProduct($item['product_type'] ?? 'normal');
$item['presale'] = 0;
$item['min_qty'] = 1;
$item['label_list'] = '';
$item['store_info'] = '';
$item['star'] = '5.0';
// 自定义表单商品不支持加入购物车(前端据此点击直接进详情)
$item['cart_button'] = $this->hasCustomForm($item['custom_form'] ?? '') || $this->getCartModeByType($item['product_type'] ?? 'normal') === 'direct' ? 0 : 1;
// 标记活动商品
if (!empty($item['activity'])) {
$item['activity'] = json_decode($item['activity'], true) ?? '';
}
}
return $list;
}
/**
* 获取产品类型列表(供后台创建商品选择/活动选商品)
* @return array
*/
public function getTypeList(): array
{
/** @var \crmeb\trade\product\ProductTypeRegistry $registry */
$registry = $this->app->make(\crmeb\trade\product\ProductTypeRegistry::class);
return $registry->allInfo();
}
}