- 新增 invoice_title、showcase、wallet_log 模型及对应语言包 - 新增后台控制器、视图及搜索字段配置 - 修复服务类目删除保护逻辑 - 优化后台登录页面文案及样式 - 新增前端角色切换逻辑及页面拦截 - 新增 SVG 图标及样式变量 - 修复接口权限及下拉框默认值问题
432 行
16 KiB
PHP
432 行
16 KiB
PHP
<?php
|
||
|
||
namespace app\admin\controller;
|
||
|
||
use app\common\controller\Backend;
|
||
use think\Db;
|
||
use think\exception\PDOException;
|
||
use think\exception\ValidateException;
|
||
use Exception;
|
||
|
||
/**
|
||
* 商品管理(商品仅定义施工标的物与细分规格,服务价格统一配置在关联的主服务与增项服务上)
|
||
*
|
||
* @icon fa fa-cubes
|
||
*/
|
||
class Service extends Backend
|
||
{
|
||
/**
|
||
* Service模型对象
|
||
* @var \app\admin\model\Service
|
||
*/
|
||
protected $model = null;
|
||
protected $relationSearch = true;
|
||
protected $searchFields = 'id,name,desc,cat.name';
|
||
|
||
public function _initialize()
|
||
{
|
||
parent::_initialize();
|
||
$this->model = new \app\admin\model\Service;
|
||
$this->view->assign("statusList", $this->model->getStatusList());
|
||
|
||
// 获取统一服务字典表(fa_service_type,不绑定单一类目,主服务与增项服务同源)
|
||
$typesPool = Db::name('service_type')
|
||
->where('status', 'normal')
|
||
->order('weigh desc, id asc')
|
||
->select();
|
||
$this->view->assign("typesPool", $typesPool);
|
||
}
|
||
|
||
public function index()
|
||
{
|
||
$this->request->filter(['strip_tags', 'trim']);
|
||
if (false === $this->request->isAjax()) {
|
||
$cats = Db::name('service_cat')
|
||
->where('status', 'normal')
|
||
->order('weigh desc, id asc')
|
||
->select();
|
||
$this->view->assign('catList', $cats);
|
||
return $this->view->fetch();
|
||
}
|
||
if ($this->request->request('keyField')) {
|
||
return $this->selectpage();
|
||
}
|
||
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||
$list = $this->model
|
||
->with(['cat'])
|
||
->where($where)
|
||
->order($sort, $order)
|
||
->paginate($limit);
|
||
|
||
$rows = $list->items();
|
||
if (!empty($rows)) {
|
||
$serviceIds = [];
|
||
foreach ($rows as $r) {
|
||
$serviceIds[] = (int)$r['id'];
|
||
}
|
||
// 一次性批量查询当前页商品的细分规格,避免 N+1 查询
|
||
$allSpecs = Db::name('service_spec')
|
||
->field('id,service_id,name,base_price,description')
|
||
->where('service_id', 'in', $serviceIds)
|
||
->where('status', 'normal')
|
||
->order('weigh desc, id asc')
|
||
->select();
|
||
$specMap = [];
|
||
foreach ($allSpecs as $sp) {
|
||
$sid = (int)$sp['service_id'];
|
||
if (!isset($specMap[$sid])) {
|
||
$specMap[$sid] = [];
|
||
}
|
||
$spPrice = (float)$sp['base_price'];
|
||
$specMap[$sid][] = $spPrice > 0 ? ($sp['name'] . '(¥' . round($spPrice) . ')') : $sp['name'];
|
||
}
|
||
foreach ($rows as &$r) {
|
||
$sid = (int)$r['id'];
|
||
$r['specs_list'] = isset($specMap[$sid]) ? $specMap[$sid] : [];
|
||
|
||
// 直接在服务端解析为数组返回,防止前端 bootstrap-table escape:true 将 JSON 双引号转义为 " 导致解析失败
|
||
$stArr = [];
|
||
if (!empty($r['service_types'])) {
|
||
$stArr = is_array($r['service_types']) ? $r['service_types'] : (json_decode($r['service_types'], true) ?: []);
|
||
}
|
||
$adArr = [];
|
||
if (!empty($r['addons'])) {
|
||
$adArr = is_array($r['addons']) ? $r['addons'] : (json_decode($r['addons'], true) ?: []);
|
||
}
|
||
$r['service_types_list'] = $stArr;
|
||
$r['addons_list'] = $adArr;
|
||
}
|
||
unset($r);
|
||
}
|
||
|
||
$result = ['total' => $list->total(), 'rows' => $rows];
|
||
return json($result);
|
||
}
|
||
|
||
/**
|
||
* 添加商品并配置关联服务定价
|
||
*/
|
||
public function add()
|
||
{
|
||
if ($this->request->isPost()) {
|
||
$params = $this->request->post("row/a");
|
||
if ($params) {
|
||
$params = $this->preExcludeFields($params);
|
||
|
||
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||
$params[$this->dataLimitField] = $this->auth->id;
|
||
}
|
||
|
||
$params = $this->formatServiceConfig($params);
|
||
|
||
$result = false;
|
||
Db::startTrans();
|
||
try {
|
||
$result = $this->model->allowField(true)->save($params);
|
||
$serviceId = $this->model->id;
|
||
|
||
// 处理商品规格列表
|
||
$this->syncSpecs($serviceId, $this->request->post('specs/a', []));
|
||
|
||
Db::commit();
|
||
} catch (ValidateException|PDOException|Exception $e) {
|
||
Db::rollback();
|
||
$this->error($e->getMessage());
|
||
}
|
||
if ($result !== false) {
|
||
$this->success();
|
||
} else {
|
||
$this->error(__('No rows were inserted'));
|
||
}
|
||
}
|
||
$this->error(__('Parameter %s can not be empty', ''));
|
||
}
|
||
return $this->view->fetch();
|
||
}
|
||
|
||
/**
|
||
* 编辑商品与关联服务定价
|
||
*/
|
||
public function edit($ids = null)
|
||
{
|
||
$row = $this->model->get($ids);
|
||
if (!$row) {
|
||
$this->error(__('No Results were found'));
|
||
}
|
||
$adminIds = $this->getDataLimitAdminIds();
|
||
if (is_array($adminIds) && !in_array($row[$this->dataLimitField], $adminIds)) {
|
||
$this->error(__('You have no permission'));
|
||
}
|
||
if ($this->request->isPost()) {
|
||
$params = $this->request->post("row/a");
|
||
if ($params) {
|
||
$params = $this->preExcludeFields($params);
|
||
$params = $this->formatServiceConfig($params);
|
||
|
||
$result = false;
|
||
Db::startTrans();
|
||
try {
|
||
$result = $row->allowField(true)->save($params);
|
||
|
||
// 同步处理商品规格列表
|
||
$this->syncSpecs($row->id, $this->request->post('specs/a', []));
|
||
|
||
Db::commit();
|
||
} catch (ValidateException|PDOException|Exception $e) {
|
||
Db::rollback();
|
||
$this->error($e->getMessage());
|
||
}
|
||
if ($result !== false) {
|
||
$this->success();
|
||
} else {
|
||
$this->error(__('No rows were updated'));
|
||
}
|
||
}
|
||
$this->error(__('Parameter %s can not be empty', ''));
|
||
}
|
||
|
||
// 解析当前商品已关联的主服务与增项服务配置
|
||
$serviceTypes = [];
|
||
if (!empty($row->service_types)) {
|
||
$serviceTypes = is_array($row->service_types) ? $row->service_types : json_decode($row->service_types, true);
|
||
}
|
||
$addons = [];
|
||
if (!empty($row->addons)) {
|
||
$addons = is_array($row->addons) ? $row->addons : json_decode($row->addons, true);
|
||
}
|
||
|
||
// 获取已有商品规格
|
||
$specs = Db::name('service_spec')
|
||
->where('service_id', $row->id)
|
||
->where('status', 'normal')
|
||
->order('weigh desc, id asc')
|
||
->select();
|
||
|
||
$catName = Db::name('service_cat')->where('id', $row->cat_id)->value('name') ?: '未归类';
|
||
|
||
$this->view->assign("row", $row);
|
||
$this->view->assign("catName", $catName);
|
||
$this->view->assign("serviceTypes", $serviceTypes ?: []);
|
||
$this->view->assign("assignedAddons", $addons ?: []);
|
||
$this->view->assign("specs", $specs ?: []);
|
||
return $this->view->fetch();
|
||
}
|
||
|
||
/**
|
||
* 商品档案与服务定价全景详情
|
||
*/
|
||
public function detail($ids = null)
|
||
{
|
||
$row = $this->model->get($ids);
|
||
if (!$row) {
|
||
$this->error(__('No Results were found'));
|
||
}
|
||
$catName = Db::name('service_cat')->where('id', $row->cat_id)->value('name') ?: '未归类';
|
||
$serviceTypes = !empty($row->service_types)
|
||
? (is_array($row->service_types) ? $row->service_types : (json_decode($row->service_types, true) ?: []))
|
||
: [];
|
||
$addons = !empty($row->addons)
|
||
? (is_array($row->addons) ? $row->addons : (json_decode($row->addons, true) ?: []))
|
||
: [];
|
||
$specs = Db::name('service_spec')
|
||
->where('service_id', $row->id)
|
||
->where('status', 'normal')
|
||
->order('weigh desc, id asc')
|
||
->select();
|
||
|
||
$orderAgg = Db::name('order')
|
||
->field('COUNT(*) AS total_cnt, SUM(CASE WHEN pay_status = 1 THEN pay_amount ELSE 0 END) AS total_gmv')
|
||
->where('service_id', $row->id)
|
||
->find();
|
||
|
||
$this->view->assign([
|
||
'row' => $row,
|
||
'catName' => $catName,
|
||
'serviceTypes' => $serviceTypes,
|
||
'addons' => $addons,
|
||
'specs' => $specs,
|
||
'orderCount' => (int)($orderAgg['total_cnt'] ?? 0),
|
||
'orderGmv' => number_format((float)($orderAgg['total_gmv'] ?? 0), 2, '.', ''),
|
||
]);
|
||
return $this->view->fetch();
|
||
}
|
||
|
||
/**
|
||
* 格式化商品关联的主服务与增项服务 JSON,并自动提取默认主服务价格用于底层索引兼容
|
||
*/
|
||
protected function formatServiceConfig($params)
|
||
{
|
||
$parsedTypes = [];
|
||
if (isset($params['service_types_raw'])) {
|
||
if (is_string($params['service_types_raw'])) {
|
||
$parsedTypes = json_decode($params['service_types_raw'], true) ?: [];
|
||
} else {
|
||
$parsedTypes = is_array($params['service_types_raw']) ? $params['service_types_raw'] : [];
|
||
}
|
||
unset($params['service_types_raw']);
|
||
|
||
if (empty($parsedTypes)) {
|
||
throw new ValidateException('请至少开启 1 项主服务');
|
||
}
|
||
|
||
$dictSupportMap = Db::name('service_type')->column('support_fixed', 'code');
|
||
$hasDefault = false;
|
||
foreach ($parsedTypes as &$pt) {
|
||
$code = isset($pt['code']) ? (string)$pt['code'] : '';
|
||
$supportFixed = isset($dictSupportMap[$code])
|
||
? (int)$dictSupportMap[$code]
|
||
: (isset($pt['support_fixed']) ? (int)$pt['support_fixed'] : 1);
|
||
$price = ($supportFixed === 1 && isset($pt['price'])) ? round((float)$pt['price'], 2) : 0.00;
|
||
$pt['price'] = $price;
|
||
$pt['support_fixed'] = $supportFixed;
|
||
if ($supportFixed === 1 && !empty($pt['is_default']) && !$hasDefault) {
|
||
$pt['is_default'] = 1;
|
||
$hasDefault = true;
|
||
} else {
|
||
$pt['is_default'] = 0;
|
||
}
|
||
}
|
||
unset($pt);
|
||
|
||
if (!$hasDefault && !empty($parsedTypes)) {
|
||
foreach ($parsedTypes as &$ptDef) {
|
||
if ((int)$ptDef['support_fixed'] === 1) {
|
||
$ptDef['is_default'] = 1;
|
||
$hasDefault = true;
|
||
break;
|
||
}
|
||
}
|
||
unset($ptDef);
|
||
if (!$hasDefault) {
|
||
$parsedTypes[0]['is_default'] = 1;
|
||
}
|
||
}
|
||
|
||
$params['service_types'] = json_encode(array_values($parsedTypes), JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
if (isset($params['addons_raw'])) {
|
||
$parsedAddons = is_string($params['addons_raw'])
|
||
? (json_decode($params['addons_raw'], true) ?: [])
|
||
: (is_array($params['addons_raw']) ? $params['addons_raw'] : []);
|
||
unset($params['addons_raw']);
|
||
$cleanAddons = [];
|
||
foreach ($parsedAddons as $idx => $ad) {
|
||
$adName = isset($ad['name']) ? trim((string)$ad['name']) : '';
|
||
$adPrice = isset($ad['price']) ? round((float)$ad['price'], 2) : 0.00;
|
||
if ($adName === '' || $adPrice <= 0) {
|
||
continue;
|
||
}
|
||
$cleanAddons[] = [
|
||
'id' => isset($ad['id']) ? (int)$ad['id'] : ($idx + 1),
|
||
'code' => isset($ad['code']) ? (string)$ad['code'] : ('addon_' . ($idx + 1)),
|
||
'name' => $adName,
|
||
'unit' => !empty($ad['unit']) ? (string)$ad['unit'] : '份',
|
||
'price' => $adPrice,
|
||
];
|
||
}
|
||
$params['addons'] = json_encode($cleanAddons, JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
// 自动从关联的主服务(优先默认首选主服务)同步底层 base_price 与 price_type
|
||
if (!empty($parsedTypes)) {
|
||
$defItem = $parsedTypes[0];
|
||
foreach ($parsedTypes as $pt) {
|
||
if (!empty($pt['is_default'])) {
|
||
$defItem = $pt;
|
||
break;
|
||
}
|
||
}
|
||
$supportFixed = isset($defItem['support_fixed']) ? (int)$defItem['support_fixed'] : 1;
|
||
$params['price_type'] = $supportFixed === 1 ? 'fixed' : 'quote';
|
||
$params['base_price'] = $supportFixed === 1 ? (float)$defItem['price'] : 0.00;
|
||
}
|
||
|
||
return $params;
|
||
}
|
||
|
||
/**
|
||
* 同步商品规格型号表(支持特殊规格单独设价覆盖,留空或0则沿用商品默认服务价)
|
||
*/
|
||
protected function syncSpecs($serviceId, $specsInput)
|
||
{
|
||
if (empty($serviceId)) return;
|
||
$now = time();
|
||
$keepIds = [];
|
||
if (!empty($specsInput) && is_array($specsInput)) {
|
||
$idx = 0;
|
||
foreach ($specsInput as $item) {
|
||
$name = trim(isset($item['name']) ? $item['name'] : '');
|
||
if ($name === '') continue;
|
||
$specDesc = isset($item['description']) ? trim($item['description']) : '';
|
||
$specPrice = (isset($item['base_price']) && is_numeric($item['base_price']) && (float)$item['base_price'] > 0)
|
||
? round((float)$item['base_price'], 2)
|
||
: 0.00;
|
||
$specId = isset($item['id']) ? (int)$item['id'] : 0;
|
||
$weigh = max(0, 100 - $idx * 5);
|
||
$idx++;
|
||
|
||
if ($specId > 0) {
|
||
Db::name('service_spec')->where('id', $specId)->where('service_id', $serviceId)->update([
|
||
'name' => $name,
|
||
'base_price' => $specPrice,
|
||
'description' => $specDesc,
|
||
'weigh' => $weigh,
|
||
'updatetime' => $now,
|
||
]);
|
||
$keepIds[] = $specId;
|
||
} else {
|
||
$newId = Db::name('service_spec')->insertGetId([
|
||
'service_id' => $serviceId,
|
||
'name' => $name,
|
||
'base_price' => $specPrice,
|
||
'description' => $specDesc,
|
||
'weigh' => $weigh,
|
||
'status' => 'normal',
|
||
'createtime' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
$keepIds[] = $newId;
|
||
}
|
||
}
|
||
}
|
||
$delQuery = Db::name('service_spec')->where('service_id', $serviceId);
|
||
if (!empty($keepIds)) {
|
||
$delQuery->whereNotIn('id', $keepIds);
|
||
}
|
||
$delQuery->delete();
|
||
}
|
||
|
||
/**
|
||
* 删除保护:已产生历史业务订单的服务商品禁止物理删除,无关联订单删除时同步清理细分规格
|
||
*/
|
||
public function del($ids = null)
|
||
{
|
||
if (false === $this->request->isPost()) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$ids = $ids ?: $this->request->post('ids');
|
||
$idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids))));
|
||
if (!$idArr) {
|
||
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||
}
|
||
$ordCnt = (int)Db::name('order')->where('service_id', 'in', $idArr)->count();
|
||
if ($ordCnt > 0) {
|
||
$this->error('所选商品已关联 ' . $ordCnt . ' 笔历史工单,禁止物理删除;如需下架请将状态设为隐藏');
|
||
}
|
||
Db::startTrans();
|
||
try {
|
||
Db::name('service_spec')->where('service_id', 'in', $idArr)->delete();
|
||
$deleted = Db::name('service')->where('id', 'in', $idArr)->delete();
|
||
Db::commit();
|
||
} catch (Exception $e) {
|
||
Db::rollback();
|
||
$this->error($e->getMessage());
|
||
}
|
||
if ($deleted) {
|
||
$this->success();
|
||
}
|
||
$this->error(__('No rows were deleted'));
|
||
}
|
||
}
|