初始化

缺陷:主题配色需要优化整体的同风格
This commit is contained in:
2026-08-14 23:21:21 +08:00
parent 6e2769c528
commit bcf54c2727
152 changed files with 13521 additions and 141 deletions

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\CardClassModel;
use App\Models\business\ColorcardModel;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 色卡分类
*/
class CardClassService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = CardClassModel::class;
$this->selectField = ['id', 'name', 'status', 'created_at', 'updated_at'];
$this->queryField = ['name' => 'like', 'status' => '='];
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
return $this->getPageList();
}
public function option(): mixed
{
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
return $this->getDetail($id);
}
public function create($params): mixed
{
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
if (ColorcardModel::whereIn('card_class', $ids)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('该分类下仍有色卡,请先调整色卡分类');
}
return $this->del($ids);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\CarouselModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 小程序轮播图
*
* CarouselService 是从 CategoryService 复制来的option() 去查 carousel 表上并不存在的
* name / pid 两列,调用即报错。这里按轮播图自己的字段重写。
*/
class CarouselService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = CarouselModel::class;
$this->selectField = ['id', 'url', 'to_path', 'sort', 'status', 'created_at', 'updated_at'];
$this->queryField = ['to_path' => 'like', 'status' => '='];
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$result = $this->getPageList();
$this->media->publicEach($result['items'], ['url']);
return $result;
}
public function option(): mixed
{
$rows = CarouselModel::where('deleted_at', 0)
->orderBy('sort')
->get(['id', 'url', 'to_path', 'sort'])
->toArray();
$this->media->publicEach($rows, ['url']);
return $rows;
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->url = $this->media->toPublic($info->url);
return $info;
}
/**
* 新增:支持一次选多张图批量建轮播
* @throws Exception
*/
public function create($params): mixed
{
$urls = $params['url'] ?? '';
$urls = is_array($urls) ? $urls : [$urls];
$now = time();
$sort = (int) ($params['sort'] ?? 0);
$rows = [];
foreach ($urls as $index => $url) {
$url = $this->media->toStorage(is_string($url) ? $url : '');
if ($url === '') {
continue;
}
$rows[] = [
'url' => $url,
'to_path' => (string) ($params['to_path'] ?? ''),
'sort' => $sort + $index,
// 缺省显示;前端可传 status批量建时统一用同一状态
'status' => (int) ($params['status'] ?? 0),
'created_at' => $now,
];
}
if (empty($rows)) {
$this->utils->errorThrow('请上传轮播图');
}
if (count($rows) === 1) {
return $this->insert($rows[0]);
}
return CarouselModel::insert($rows);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
if (array_key_exists('url', $params)) {
$params['url'] = $this->media->firstOf($params['url']);
}
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
return $this->del(is_array($id) ? $id : [$id]);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\CatalogueModel;
use App\Models\business\CategoryModel;
use App\Models\business\ImageModel;
use App\Models\business\PriceSheetModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 商品图册
*/
class CatalogueService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = CatalogueModel::class;
$this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'price', 'alias', 'identifier', 'status', 'created_at', 'updated_at'];
$this->queryField = ['title' => 'like', 'alias' => 'like', 'identifier' => 'like', 'status' => '='];
$this->media = MediaUrlService::getInstance();
}
/**
* 列表
*
* 两处沿用老行为:
* 1. 按分类筛选时连同该分类的子分类一起查,否则选了父分类会一条都搜不到
* 2. 报价单里 6 个历史材质列绝大多数为空只把有值的列名回给前端show_field
* 前端据此决定表格显示哪几列
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$this->with = ['category', 'priceSheet'];
$categoryId = (int) request()->get('category_id', 0);
if ($categoryId > 0) {
$ids = CategoryModel::where('pid', $categoryId)->where('deleted_at', 0)->pluck('id')->all();
$ids[] = $categoryId;
$this->whereIn = ['category_id', $ids];
}
$result = $this->getPageList();
foreach ($result['items'] as &$item) {
$item['category_name'] = $item['category']['name'] ?? '';
unset($item['category']);
$item['cover'] = $this->media->toPublic($item['cover'] ?? '');
$item['pdf'] = $this->media->toPublic($item['pdf'] ?? '');
$item['show_field'] = $this->pickUsedMaterialFields($item['price_sheet'] ?? []);
}
unset($item);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'title as name', 'identifier'];
return $this->getOption();
}
/**
* 详情,带规格与两类相册
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->cover = $this->media->toPublic($info->cover);
$info->pdf = $this->media->toPublic($info->pdf);
$priceSheet = PriceSheetModel::where('catalogue_id', $id)
->where('deleted_at', 0)
->orderBy('id')
->get()
->toArray();
$info->show_field = $this->pickUsedMaterialFields($priceSheet);
$info->price_sheet = $priceSheet;
$info->render_images = $this->imagesOf($id, ImageModel::TYPE_RENDER);
$info->physical_images = $this->imagesOf($id, ImageModel::TYPE_PHYSICAL);
return $info;
}
public function create($params): mixed
{
$params = $this->normalize($params);
$this->assertIdentifierUnique((string) ($params['identifier'] ?? ''));
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
$params = $this->normalize($params);
if (array_key_exists('identifier', $params)) {
$this->assertIdentifierUnique((string) $params['identifier'], (int) $id);
}
return $this->save($id, $params);
}
/**
* 删除商品:连带软删它的规格与相册,否则会留下一堆查不到主体的孤儿数据
* @throws Exception
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
$now = time();
PriceSheetModel::whereIn('catalogue_id', $ids)->where('deleted_at', 0)
->update(['deleted_at' => $now, 'updated_at' => $now]);
ImageModel::whereIn('catalogue_id', $ids)->where('deleted_at', 0)
->update(['deleted_at' => $now, 'updated_at' => $now]);
return $this->del($ids);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
/**
* 报价单里真正有值的材质列
*/
private function pickUsedMaterialFields(array $priceSheetRows): array
{
$used = [];
foreach ($priceSheetRows as $row) {
foreach (PriceSheetModel::MATERIAL_FIELDS as $field) {
if (!empty($row[$field] ?? '')) {
$used[$field] = true;
}
}
}
return array_keys($used);
}
private function imagesOf(int|string $catalogueId, int $type): array
{
$rows = ImageModel::where('catalogue_id', $catalogueId)
->where('type', $type)
->where('deleted_at', 0)
->orderBy('id')
->get(['id', 'url', 'type'])
->toArray();
$this->media->publicEach($rows, ['url']);
return $rows;
}
private function normalize(array $params): array
{
foreach (['cover', 'pdf'] as $field) {
if (array_key_exists($field, $params)) {
$params[$field] = $this->media->firstOf($params[$field]);
}
}
return $params;
}
/**
* 商品编号是小程序搜索和线下对单的依据,重复了就没法定位货品
* @throws Exception
*/
private function assertIdentifierUnique(string $identifier, int $exceptId = 0): void
{
$identifier = trim($identifier);
if ($identifier === '') {
return;
}
$exists = CatalogueModel::where('identifier', $identifier)
->where('deleted_at', 0)
->when($exceptId > 0, fn ($q) => $q->where('id', '<>', $exceptId))
->exists();
if ($exists) {
$this->utils->errorThrow('商品编号已存在:' . $identifier);
}
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\CatalogueModel;
use App\Models\business\CategoryModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 商品分类
*/
class CategoryService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = CategoryModel::class;
$this->selectField = ['id', 'name', 'url', 'pid', 'sort', 'status', 'created_at', 'updated_at'];
$this->queryField = ['name' => 'like', 'pid' => '=', 'status' => '='];
// 有 sort 列后按排序值,同值再按 id保证稳定
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$result = $this->getPageList();
$this->media->publicEach($result['items'], ['url']);
return $result;
}
/**
* 分类树。老接口在首位塞了一个 {id:0,name:'全部'} 供小程序做「全部」标签用,
* 这里保留该行为,但只在显式要求时加,后台表单选上级时不需要它。
*/
public function option(): array
{
$withAll = filter_var(request()->get('with_all', false), FILTER_VALIDATE_BOOLEAN);
$rows = CategoryModel::where('deleted_at', 0)
->orderBy('sort', 'asc')
->orderBy('id', 'asc')
->get(['id', 'name', 'url', 'pid', 'sort'])
->toArray();
$this->media->publicEach($rows, ['url']);
$tree = $this->utils->tree($rows);
if ($withAll) {
array_unshift($tree, ['id' => 0, 'name' => '全部', 'url' => '', 'pid' => 0]);
}
return $tree;
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->url = $this->media->toPublic($info->url);
return $info;
}
public function create($params): mixed
{
$params['url'] = $this->media->firstOf($params['url'] ?? '');
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
if (array_key_exists('url', $params)) {
$params['url'] = $this->media->firstOf($params['url']);
}
if (array_key_exists('pid', $params)) {
$pid = (int) $params['pid'];
if ($pid === (int) $id) {
$this->utils->errorThrow('上级分类不能是自己');
}
}
return $this->save($id, $params);
}
/**
* 删除分类:有子分类或仍挂着商品时拒绝,避免商品失去归属后在小程序里查不到
* @throws Exception
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
if (CategoryModel::whereIn('pid', $ids)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('存在子分类,请先删除子分类');
}
if (CatalogueModel::whereIn('category_id', $ids)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('该分类下仍有商品,请先调整商品分类');
}
return $this->del($ids);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\ColorcardModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 色卡
*/
class ColorcardService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = ColorcardModel::class;
$this->selectField = ['id', 'card_class', 'company', 'price', 'description', 'cover', 'status', 'created_at', 'updated_at'];
$this->queryField = ['card_class' => '=', 'company' => '=', 'description' => 'like', 'status' => '='];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$this->with = ['cardClassInfo', 'companyInfo'];
$result = $this->getPageList();
foreach ($result['items'] as &$item) {
$item['card_class_name'] = $item['card_class_info']['name'] ?? '';
$item['company_name'] = $item['company_info']['name'] ?? '';
unset($item['card_class_info'], $item['company_info']);
$item['cover'] = $this->media->toPublic($item['cover'] ?? '');
}
unset($item);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'description as name'];
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->cover = $this->media->toPublic($info->cover);
return $info;
}
public function create($params): mixed
{
if (array_key_exists('cover', $params)) {
$params['cover'] = $this->media->firstOf($params['cover']);
}
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
if (array_key_exists('cover', $params)) {
$params['cover'] = $this->media->firstOf($params['cover']);
}
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
return $this->del(is_array($id) ? $id : [$id]);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\ColorcardModel;
use App\Models\business\CompanyModel;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 色卡所属公司
*/
class CompanyService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = CompanyModel::class;
$this->selectField = ['id', 'name', 'phone', 'address', 'status', 'created_at', 'updated_at'];
$this->queryField = ['name' => 'like', 'phone' => 'like', 'address' => 'like', 'status' => '='];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
return $this->getPageList();
}
public function option(): mixed
{
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
return $this->getDetail($id);
}
public function create($params): mixed
{
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
if (ColorcardModel::whereIn('company', $ids)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('该公司下仍有色卡,请先调整色卡所属公司');
}
return $this->del($ids);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\EnterpriseModel;
use App\Models\business\WxUserModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 企业管理
*
* 老项目里企业只是微信用户管理页顺手塞的两个字段name/logo
* 没有独立模块,也没有联系人、税号、默认倍率,做不了对公结算。
*/
class EnterpriseService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = EnterpriseModel::class;
$this->selectField = [
'id', 'name', 'logo', 'contact_name', 'phone', 'address',
'tax_no', 'settle_type', 'price_number', 'status', 'remark',
'created_at', 'updated_at',
];
$this->queryField = [
'name' => 'like',
'contact_name' => 'like',
'phone' => 'like',
'status' => '=',
];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$result = $this->getPageList();
$ids = array_column($result['items'], 'id');
$counts = empty($ids)
? []
: WxUserModel::whereIn('enterprise_id', $ids)
->where('deleted_at', 0)
->selectRaw('enterprise_id, COUNT(*) as total')
->groupBy('enterprise_id')
->pluck('total', 'enterprise_id')
->all();
foreach ($result['items'] as &$item) {
$item['logo'] = $this->media->toPublic($item['logo'] ?? '');
$item['user_count'] = (int) ($counts[$item['id']] ?? 0);
}
unset($item);
return $result;
}
/**
* 下拉SearchSelect 组件按关键词模糊搜索,所以支持 keyword 入参
*/
public function option(): mixed
{
$keyword = trim((string) request()->get('keyword', ''));
$limit = (int) request()->get('limit', 30);
$limit = $limit > 0 && $limit <= 100 ? $limit : 30;
return EnterpriseModel::where('deleted_at', 0)
->where('status', 0)
->when($keyword !== '', function ($q) use ($keyword) {
$q->where(function ($sub) use ($keyword) {
$sub->where('name', 'like', "%{$keyword}%")
->orWhere('contact_name', 'like', "%{$keyword}%")
->orWhere('phone', 'like', "%{$keyword}%");
});
})
->orderBy('id', 'desc')
->limit($limit)
->get(['id', 'name', 'logo', 'contact_name', 'phone', 'price_number'])
->map(function ($row) {
$row->logo = $this->media->toPublic($row->logo);
return $row;
});
}
/**
* 详情:带企业下的微信用户
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->logo = $this->media->toPublic($info->logo);
$info->users = WxUserModel::where('enterprise_id', $id)
->where('deleted_at', 0)
->orderBy('id', 'desc')
->get(['id', 'nick_name', 'phone', 'is_p', 'show_price', 'price_number', 'created_at'])
->toArray();
$info->user_count = count($info->users);
return $info;
}
public function create($params): mixed
{
$params = $this->normalize($params);
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
$params = $this->normalize($params);
return $this->save($id, $params);
}
/**
* 删除企业前先解绑用户,否则用户会挂在一个查不到的企业上
* @throws Exception
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
if (WxUserModel::whereIn('enterprise_id', $ids)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('该企业下仍有微信用户,请先解绑用户');
}
return $this->del($ids);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
private function normalize(array $params): array
{
if (array_key_exists('logo', $params)) {
$params['logo'] = $this->media->firstOf($params['logo']);
}
if (array_key_exists('price_number', $params)) {
$number = $params['price_number'];
$params['price_number'] = !is_numeric($number) || (float) $number <= 0
? '1'
: (string) $number;
}
return $params;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\FactoryClassificationModel;
use App\Models\business\FactoryInfoModel;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 工厂分类
*/
class FactoryClassificationService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = FactoryClassificationModel::class;
$this->selectField = ['id', 'name', 'status', 'created_at', 'updated_at'];
$this->queryField = ['name' => 'like', 'status' => '='];
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
return $this->getPageList();
}
public function option(): mixed
{
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
return $this->getDetail($id);
}
public function create($params): mixed
{
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
if (FactoryInfoModel::whereIn('classification', $ids)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('该分类下仍有工厂,请先调整工厂分类');
}
return $this->del($ids);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\FactoryImageModel;
use App\Models\business\FactoryInfoModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 工厂产品图
*/
class FactoryImageService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = FactoryImageModel::class;
$this->selectField = ['id', 'factory', 'url', 'status', 'created_at', 'updated_at'];
$this->queryField = ['factory' => '=', 'status' => '='];
$this->orderBy = ['name' => 'id', 'sort' => 'desc'];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$this->with = ['factoryInfo'];
$result = $this->getPageList();
foreach ($result['items'] as &$item) {
$item['factory_name'] = $item['factory_info']['name'] ?? '';
unset($item['factory_info']);
$item['url'] = $this->media->toPublic($item['url'] ?? '');
}
unset($item);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'url as name'];
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->url = $this->media->toPublic($info->url);
return $info;
}
/**
* 某工厂的全部产品图(老接口 factory-image/image-list
*/
public function imageList(int $factoryId): array
{
if ($factoryId <= 0) {
return [];
}
$rows = FactoryImageModel::where('factory', $factoryId)
->where('deleted_at', 0)
->orderBy('id')
->get(['id', 'factory', 'url', 'status'])
->toArray();
$this->media->publicEach($rows, ['url']);
return $rows;
}
/**
* 新增:一次可上传多张
* @throws Exception
*/
public function create($params): mixed
{
$factoryId = (int) ($params['factory'] ?? 0);
if (!FactoryInfoModel::where('id', $factoryId)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('工厂不存在');
}
$urls = $params['url'] ?? '';
$urls = is_array($urls) ? $urls : [$urls];
$now = time();
$rows = [];
foreach ($urls as $url) {
$url = $this->media->toStorage(is_string($url) ? $url : '');
if ($url === '') {
continue;
}
$rows[] = [
'factory' => $factoryId,
'url' => $url,
'created_at' => $now,
];
}
if (empty($rows)) {
$this->utils->errorThrow('请上传图片');
}
if (count($rows) === 1) {
return $this->insert($rows[0]);
}
return FactoryImageModel::insert($rows);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
if (array_key_exists('url', $params)) {
$params['url'] = $this->media->firstOf($params['url']);
}
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
return $this->del(is_array($id) ? $id : [$id]);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\FactoryImageModel;
use App\Models\business\FactoryInfoModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 工厂管理
*/
class FactoryInfoService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = FactoryInfoModel::class;
$this->selectField = ['id', 'name', 'phone', 'classification', 'cover', 'address', 'status', 'created_at', 'updated_at'];
$this->queryField = ['name' => 'like', 'phone' => 'like', 'classification' => '=', 'status' => '='];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$this->with = ['classificationInfo'];
$result = $this->getPageList();
foreach ($result['items'] as &$item) {
$item['classification_name'] = $item['classification_info']['name'] ?? '';
unset($item['classification_info']);
$item['cover'] = $this->media->toPublic($item['cover'] ?? '');
}
unset($item);
return $result;
}
public function option(): mixed
{
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->cover = $this->media->toPublic($info->cover);
return $info;
}
public function create($params): mixed
{
if (array_key_exists('cover', $params)) {
$params['cover'] = $this->media->firstOf($params['cover']);
}
return $this->insert($params);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
if (array_key_exists('cover', $params)) {
$params['cover'] = $this->media->firstOf($params['cover']);
}
return $this->save($id, $params);
}
/**
* 删除工厂时连带软删它的产品图,避免留下查不到工厂的图片
* @throws Exception
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
$now = time();
FactoryImageModel::whereIn('factory', $ids)->where('deleted_at', 0)
->update(['deleted_at' => $now, 'updated_at' => $now]);
return $this->del($ids);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\CatalogueModel;
use App\Models\business\ImageModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 商品相册(渲染图 / 实物图)
*/
class ImageService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = ImageModel::class;
$this->selectField = ['id', 'catalogue_id', 'url', 'type', 'status', 'created_at', 'updated_at'];
$this->queryField = ['catalogue_id' => '=', 'type' => '=', 'status' => '='];
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$result = $this->getPageList();
$this->media->publicEach($result['items'], ['url']);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'url as name'];
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
$info = $this->getDetail($id);
$info->url = $this->media->toPublic($info->url);
return $info;
}
/**
* 渲染图列表(老接口 image/get-render-graph入参是 catalogue_id
*/
public function renderGraph(int $catalogueId): array
{
return $this->listByCatalogue($catalogueId, ImageModel::TYPE_RENDER);
}
/**
* 实物图列表(老接口 image/get-physical-drawing入参是 catalogue_id
*/
public function physicalDrawing(int $catalogueId): array
{
return $this->listByCatalogue($catalogueId, ImageModel::TYPE_PHYSICAL);
}
/**
* 新增前端图片组件一次能选多张url 传数组时逐张入库
* @throws Exception
*/
public function create($params): mixed
{
$catalogueId = (int) ($params['catalogue_id'] ?? 0);
if (!CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('商品不存在');
}
$type = (int) ($params['type'] ?? ImageModel::TYPE_RENDER);
$urls = $params['url'] ?? '';
$urls = is_array($urls) ? $urls : [$urls];
$now = time();
$rows = [];
foreach ($urls as $url) {
$url = $this->media->toStorage(is_string($url) ? $url : '');
if ($url === '') {
continue;
}
$rows[] = [
'catalogue_id' => $catalogueId,
'url' => $url,
'type' => $type,
'created_at' => $now,
];
}
if (empty($rows)) {
$this->utils->errorThrow('请上传图片');
}
if (count($rows) === 1) {
return $this->insert($rows[0]);
}
return ImageModel::insert($rows);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
if (array_key_exists('url', $params)) {
$params['url'] = $this->media->firstOf($params['url']);
}
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
return $this->del(is_array($id) ? $id : [$id]);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
private function listByCatalogue(int $catalogueId, int $type): array
{
if ($catalogueId <= 0) {
return [];
}
$rows = ImageModel::where('catalogue_id', $catalogueId)
->where('type', $type)
->where('deleted_at', 0)
->orderBy('id')
->get(['id', 'catalogue_id', 'url', 'type', 'status'])
->toArray();
$this->media->publicEach($rows, ['url']);
return $rows;
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\ListItemModel;
use App\Models\business\ListModel;
use App\Models\business\OrderModel;
use App\Models\business\WxUserModel;
/**
* 清单管理(后台)
*
* 后台看清单是为了帮客户报价与下单,所以详情里带的是「按该用户倍率算过的价格」,
* 与用户在小程序里看到的一致,否则电话里报的价和客户手机上的价对不上。
*/
class ListService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = ListModel::class;
$this->selectField = [
'id', 'list_no', 'name', 'user_id', 'enterprise_id', 'remark',
'status', 'created_at', 'updated_at',
];
$this->queryField = [
'list_no' => 'like',
'name' => 'like',
'user_id' => '=',
'enterprise_id' => '=',
'status' => '=',
];
$this->with = ['user', 'enterprise'];
}
public function list(): array
{
$keyword = trim((string) request()->get('user_keyword', ''));
if ($keyword !== '') {
// 前端只给一个「客户」输入框,昵称与手机号都要能搜到
$userIds = WxUserModel::where('deleted_at', 0)
->where(function ($query) use ($keyword) {
$query->where('nick_name', 'like', '%' . $keyword . '%')
->orWhere('phone', 'like', '%' . $keyword . '%');
})->pluck('id')->all();
$this->whereIn = ['user_id', empty($userIds) ? [0] : $userIds];
}
$result = $this->getPageList();
$listIds = array_column($result['items'], 'id');
$counts = ListItemModel::whereIn('list_id', $listIds)
->where('deleted_at', 0)
->selectRaw('list_id, count(*) as total, sum(quantity) as quantity')
->groupBy('list_id')
->get()
->keyBy('list_id');
$orderCounts = OrderModel::whereIn('list_id', $listIds)
->where('deleted_at', 0)
->selectRaw('list_id, count(*) as total')
->groupBy('list_id')
->get()
->keyBy('list_id');
foreach ($result['items'] as &$item) {
$item['item_count'] = (int) ($counts[$item['id']]['total'] ?? 0);
$item['quantity'] = (int) ($counts[$item['id']]['quantity'] ?? 0);
$item['order_count'] = (int) ($orderCounts[$item['id']]['total'] ?? 0);
$item['user_name'] = $item['user']['nick_name'] ?? '';
$item['user_phone'] = $item['user']['phone'] ?? '';
$item['enterprise_name'] = $item['enterprise']['name'] ?? '';
}
unset($item);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'name'];
return $this->getOption();
}
/**
* 详情:带明细与算过倍率的价格
*/
public function detail($id): mixed
{
$info = ListModel::with([
'user',
'enterprise',
'items' => fn ($query) => $query->where('deleted_at', 0),
'items.catalogue',
'items.priceSheet',
])->where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
return $this->utils->notFound('清单不存在');
}
$info = $info->toArray();
$price = PriceService::getInstance();
$multiplier = $info['user']['price_number'] ?? 1;
$total = 0;
foreach ($info['items'] as &$item) {
$routine = $item['price_sheet']['routine'] ?? '';
$unit = (int) $item['unit_price'];
if ($unit <= 0) {
$unit = $price->resolveUnitPrice($routine, (string) $item['material_key'], $multiplier);
}
$item['unit_price'] = $unit;
$item['unit_price_text'] = $price->centsToYuan($unit);
$item['total_price'] = $unit * max(1, (int) $item['quantity']);
$item['routine_list'] = $price->formatRoutine($routine, true, $multiplier);
$total += $item['total_price'];
}
unset($item);
$info['total_amount'] = $total;
$info['total_amount_text'] = $price->centsToYuan($total);
return $info;
}
public function create($params): mixed
{
$params['list_no'] = SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no');
return $this->insert($params);
}
public function update($id, $params): mixed
{
unset($params['list_no'], $params['user_id']);
return $this->save($id, $params);
}
public function delete($ids): mixed
{
return $this->del($ids);
}
/**
* 某个用户的全部清单,用户详情模态框里用
*/
public function byUser(int $userId): array
{
$rows = ListModel::where('user_id', $userId)->where('deleted_at', 0)->orderBy('id', 'desc')->get([
'id', 'list_no', 'name', 'status', 'created_at',
])->toArray();
$counts = ListItemModel::whereIn('list_id', array_column($rows, 'id'))
->where('deleted_at', 0)
->selectRaw('list_id, count(*) as total')
->groupBy('list_id')
->get()
->keyBy('list_id');
foreach ($rows as &$row) {
$row['item_count'] = (int) ($counts[$row['id']]['total'] ?? 0);
}
unset($row);
return $rows;
}
/**
* 后台代客下单
*/
public function toOrder(int $listId, array $params): array
{
return OrderCoreService::getInstance()->createFromList($listId, 0, $params);
}
/**
* 改明细(后台帮客户补规格与数量)
*/
public function saveItem(array $params): mixed
{
$itemId = (int) ($params['id'] ?? 0);
if ($itemId <= 0) {
$this->utils->errorThrow('参数错误');
}
$update = ['updated_at' => time()];
foreach (['price_sheet_id', 'quantity', 'unit_price'] as $field) {
if (array_key_exists($field, $params)) {
$update[$field] = (int) $params[$field];
}
}
foreach (['material_key', 'remark'] as $field) {
if (array_key_exists($field, $params)) {
$update[$field] = (string) $params[$field];
}
}
return ListItemModel::where('id', $itemId)->update($update);
}
/**
* 删明细
*/
public function deleteItem(array|int $ids): mixed
{
return ListItemModel::whereIn('id', (array) $ids)->update([
'deleted_at' => time(),
'updated_at' => time(),
]);
}
}

View File

@@ -0,0 +1,383 @@
<?php
namespace App\Service\business;
use App\Models\business\ListItemModel;
use App\Models\business\ListModel;
use App\Models\business\OrderDeliveryModel;
use App\Models\business\OrderItemModel;
use App\Models\business\OrderModel;
use App\Models\business\OrderPaymentModel;
use App\Models\business\WxUserModel;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\DB;
/**
* 订单核心逻辑(不含鉴权)
*
* 后台OrderService管理员身份与小程序WxOrderService微信用户身份都要用同一套
* 建单、收款、发货规则。鉴权基类不同,所以规则收在这个中立类里,两边只做身份校验与参数整理。
*
* 三条纪律:金额一律整数分;状态只能通过 transition 迁移;支付确认必须行锁 + 幂等。
*/
class OrderCoreService
{
private static mixed $_instance;
/**
* 允许的状态迁移,其余一律拒绝
*/
private const TRANSITIONS = [
OrderModel::STATUS_UNPAID => [OrderModel::STATUS_PAID, OrderModel::STATUS_CANCELLED],
OrderModel::STATUS_PAID => [OrderModel::STATUS_SHIPPED, OrderModel::STATUS_CANCELLED],
OrderModel::STATUS_SHIPPED => [OrderModel::STATUS_DONE],
OrderModel::STATUS_DONE => [],
OrderModel::STATUS_CANCELLED => [],
];
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 由清单生成订单
*
* @param int $listId 清单 ID
* @param int $userId 下单用户cc_wx_user.id
* @param array $params receiver_name/receiver_phone/receiver_address/delivery_type/remark
* @return array 新订单详情
*/
public function createFromList(int $listId, int $userId, array $params = []): array
{
$list = ListModel::where('id', $listId)->where('deleted_at', 0)->first();
if (empty($list)) {
UtilsService::getInstance()->errorThrow('清单不存在');
}
if ($userId > 0 && (int) $list['user_id'] !== $userId) {
UtilsService::getInstance()->errorThrow('不能对他人的清单下单');
}
$userId = (int) $list['user_id'];
$items = ListItemModel::with([
'catalogue',
'priceSheet',
])->where('list_id', $listId)->where('deleted_at', 0)->get();
if ($items->isEmpty()) {
UtilsService::getInstance()->errorThrow('清单里还没有商品');
}
$user = WxUserModel::where('id', $userId)->first();
$multiplier = $user['price_number'] ?? 1;
$price = PriceService::getInstance();
$rows = [];
$missing = [];
$total = 0;
foreach ($items as $item) {
$catalogue = $item->catalogue;
if (empty($catalogue)) {
continue;
}
$sheet = $item->priceSheet;
if (empty($sheet)) {
// 老清单没有 price_sheet_id缺规格的行必须让用户回清单补选不能瞎猜一个价格
$missing[] = $catalogue['title'] ?? ('#' . $item['catalogue_id']);
continue;
}
$quantity = max(1, (int) $item['quantity']);
$unitPrice = (int) $item['unit_price'];
if ($unitPrice <= 0) {
$unitPrice = $price->resolveUnitPrice($sheet['routine'] ?? '', (string) $item['material_key'], $multiplier);
}
$lineTotal = $unitPrice * $quantity;
$total += $lineTotal;
$rows[] = [
'catalogue_id' => (int) $item['catalogue_id'],
'price_sheet_id' => (int) $item['price_sheet_id'],
'title' => (string) ($catalogue['title'] ?? ''),
'cover' => (string) ($catalogue['cover'] ?? ''),
'alias' => (string) ($catalogue['alias'] ?? ''),
'specification' => (string) ($sheet['specification'] ?? ''),
'dimension' => (string) ($sheet['dimension'] ?? ''),
'material_key' => (string) ($item['material_key'] ?? 'routine'),
'quantity' => $quantity,
'unit_price' => $unitPrice,
'total_price' => $lineTotal,
'remark' => (string) ($item['remark'] ?? ''),
'created_at' => time(),
];
}
if (!empty($missing)) {
UtilsService::getInstance()->errorThrow('以下商品还没有选规格:' . implode('、', array_slice($missing, 0, 5)));
}
if (empty($rows)) {
UtilsService::getInstance()->errorThrow('清单里没有可下单的商品');
}
$orderId = 0;
DB::connection('business')->transaction(function () use (&$orderId, $list, $userId, $user, $params, $rows, $total) {
$now = time();
$orderId = OrderModel::insertGetId([
'order_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_ORDER, 'order', 'order_no'),
'list_id' => (int) $list['id'],
'user_id' => $userId,
'enterprise_id' => (int) ($list['enterprise_id'] ?: ($user['enterprise_id'] ?? 0)),
'total_amount' => $total,
'delivery_type' => (int) ($params['delivery_type'] ?? 0),
'receiver_name' => (string) ($params['receiver_name'] ?? ($user['nick_name'] ?? '')),
'receiver_phone' => (string) ($params['receiver_phone'] ?? ($user['phone'] ?? '')),
'receiver_address' => (string) ($params['receiver_address'] ?? ''),
'remark' => (string) ($params['remark'] ?? ''),
'status' => OrderModel::STATUS_UNPAID,
'created_at' => $now,
]);
foreach ($rows as &$row) {
$row['order_id'] = $orderId;
}
unset($row);
OrderItemModel::insert($rows);
ListModel::where('id', $list['id'])->update(['status' => 1, 'updated_at' => $now]);
});
return $this->detail($orderId);
}
/**
* 订单详情(含明细、支付记录、发货记录)
*/
public function detail(int $orderId): array
{
$order = OrderModel::with([
'items' => fn ($query) => $query->where('deleted_at', 0),
'payments' => fn ($query) => $query->where('deleted_at', 0)->orderBy('id', 'desc'),
'deliveries' => fn ($query) => $query->where('deleted_at', 0)->orderBy('id', 'desc'),
'user',
'enterprise',
])->where('id', $orderId)->where('deleted_at', 0)->first();
if (empty($order)) {
UtilsService::getInstance()->errorThrow('订单不存在');
}
$order = $order->toArray();
$order['voucher_list'] = [];
foreach ($order['payments'] ?? [] as $payment) {
foreach (array_filter(explode(',', (string) $payment['voucher'])) as $image) {
$order['voucher_list'][] = $image;
}
}
return $order;
}
/**
* 提交转账凭证,进入待审核
*/
public function submitVoucher(int $orderId, array $params, int $userId = 0): array
{
$order = $this->lockOrder($orderId, $userId);
if ((int) $order['pay_status'] === OrderModel::PAY_STATUS_PAID) {
UtilsService::getInstance()->errorThrow('订单已付款');
}
$voucher = $params['voucher'] ?? '';
$voucher = is_array($voucher) ? implode(',', array_filter($voucher)) : (string) $voucher;
if ($voucher === '') {
UtilsService::getInstance()->errorThrow('请上传转账凭证');
}
$amount = (int) ($params['amount'] ?? 0);
if ($amount <= 0) {
$amount = (int) $order['total_amount'] - (int) $order['paid_amount'];
}
$now = time();
DB::connection('business')->transaction(function () use ($orderId, $voucher, $amount, $now) {
OrderPaymentModel::insert([
'order_id' => $orderId,
'pay_type' => OrderModel::PAY_TYPE_VOUCHER,
'amount' => $amount,
'voucher' => $voucher,
// 转账没有微信单号,用订单 + 时间占位,仍受唯一索引约束防重复提交
'out_trade_no' => 'TR' . $orderId . '_' . $now,
'status' => OrderPaymentModel::STATUS_AUDITING,
'created_at' => $now,
]);
OrderModel::where('id', $orderId)->update([
'pay_type' => OrderModel::PAY_TYPE_VOUCHER,
'pay_status' => OrderModel::PAY_STATUS_AUDITING,
'updated_at' => $now,
]);
});
return $this->detail($orderId);
}
/**
* 审核转账凭证
*
* @param int $status OrderPaymentModel::STATUS_CONFIRMED|STATUS_REJECTED
*/
public function auditPayment(int $paymentId, int $status, int $adminId, string $remark = ''): array
{
$orderId = 0;
DB::connection('business')->transaction(function () use ($paymentId, $status, $adminId, $remark, &$orderId) {
$payment = OrderPaymentModel::where('id', $paymentId)->lockForUpdate()->first();
if (empty($payment)) {
UtilsService::getInstance()->errorThrow('支付记录不存在');
}
if ((int) $payment['status'] !== OrderPaymentModel::STATUS_AUDITING) {
UtilsService::getInstance()->errorThrow('该支付记录已处理');
}
$orderId = (int) $payment['order_id'];
$now = time();
OrderPaymentModel::where('id', $paymentId)->update([
'status' => $status,
'auditor_id' => $adminId,
'audited_at' => $now,
'audit_remark' => $remark,
'updated_at' => $now,
]);
if ($status !== OrderPaymentModel::STATUS_CONFIRMED) {
OrderModel::where('id', $orderId)->update([
'pay_status' => OrderModel::PAY_STATUS_REJECTED,
'updated_at' => $now,
]);
return;
}
$this->applyPaid($orderId, (int) $payment['amount'], $now);
});
return $this->detail($orderId);
}
/**
* 记一笔收款并推进订单状态
*
* 收款可能分多笔,只有累计金额够了才算付清,否则停在部分收款。
*/
public function applyPaid(int $orderId, int $amount, int $now = 0): void
{
$now = $now ?: time();
$order = OrderModel::where('id', $orderId)->lockForUpdate()->first();
if (empty($order)) {
UtilsService::getInstance()->errorThrow('订单不存在');
}
$paid = (int) $order['paid_amount'] + $amount;
$update = [
'paid_amount' => $paid,
'updated_at' => $now,
];
if ($paid >= (int) $order['total_amount']) {
$update['pay_status'] = OrderModel::PAY_STATUS_PAID;
$update['paid_at'] = $now;
if ($this->canTransition((int) $order['status'], OrderModel::STATUS_PAID)) {
$update['status'] = OrderModel::STATUS_PAID;
}
}
OrderModel::where('id', $orderId)->update($update);
}
/**
* 微信支付回调落账(幂等)
*
* 微信会重复推送同一笔,靠 out_trade_no 唯一索引 + 状态判断挡住重复入账。
*/
public function confirmWechatPay(string $outTradeNo, string $transactionId, int $amount): bool
{
$done = false;
DB::connection('business')->transaction(function () use ($outTradeNo, $transactionId, $amount, &$done) {
$payment = OrderPaymentModel::where('out_trade_no', $outTradeNo)->lockForUpdate()->first();
if (empty($payment)) {
return;
}
if ((int) $payment['status'] === OrderPaymentModel::STATUS_CONFIRMED) {
$done = true;
return;
}
$now = time();
OrderPaymentModel::where('id', $payment['id'])->update([
'status' => OrderPaymentModel::STATUS_CONFIRMED,
'transaction_id' => $transactionId,
'amount' => $amount > 0 ? $amount : (int) $payment['amount'],
'audited_at' => $now,
'updated_at' => $now,
]);
$this->applyPaid((int) $payment['order_id'], $amount > 0 ? $amount : (int) $payment['amount'], $now);
$done = true;
});
return $done;
}
/**
* 发货:物流 / 自提 / 公司配送
*/
public function ship(int $orderId, array $params, int $adminId): array
{
$order = $this->lockOrder($orderId);
$type = (int) ($params['delivery_type'] ?? OrderModel::DELIVERY_EXPRESS);
if ($type === OrderModel::DELIVERY_EXPRESS && trim((string) ($params['tracking_no'] ?? '')) === '') {
UtilsService::getInstance()->errorThrow('请填写运单号');
}
if ($type === OrderModel::DELIVERY_PICKUP && trim((string) ($params['pickup_point'] ?? '')) === '') {
UtilsService::getInstance()->errorThrow('请填写自提点');
}
if (!$this->canTransition((int) $order['status'], OrderModel::STATUS_SHIPPED)) {
UtilsService::getInstance()->errorThrow('当前订单状态不允许发货');
}
$now = time();
DB::connection('business')->transaction(function () use ($orderId, $params, $type, $adminId, $now) {
OrderDeliveryModel::insert([
'order_id' => $orderId,
'delivery_type' => $type,
'company' => (string) ($params['company'] ?? ''),
'tracking_no' => (string) ($params['tracking_no'] ?? ''),
'pickup_point' => (string) ($params['pickup_point'] ?? ''),
'driver_info' => (string) ($params['driver_info'] ?? ''),
'shipped_at' => $now,
'remark' => (string) ($params['remark'] ?? ''),
'operator_id' => $adminId,
'created_at' => $now,
]);
OrderModel::where('id', $orderId)->update([
'delivery_type' => $type,
'status' => OrderModel::STATUS_SHIPPED,
'updated_at' => $now,
]);
});
return $this->detail($orderId);
}
/**
* 状态迁移(取消、完成等)
*/
public function transition(int $orderId, int $target, int $userId = 0): array
{
$order = $this->lockOrder($orderId, $userId);
if (!$this->canTransition((int) $order['status'], $target)) {
UtilsService::getInstance()->errorThrow('当前状态不允许该操作');
}
OrderModel::where('id', $orderId)->update([
'status' => $target,
'updated_at' => time(),
]);
return $this->detail($orderId);
}
public function canTransition(int $from, int $to): bool
{
return in_array($to, self::TRANSITIONS[$from] ?? [], true);
}
/**
* 取订单并做归属校验($userId > 0 时限定本人)
*/
private function lockOrder(int $orderId, int $userId = 0): array
{
$order = OrderModel::where('id', $orderId)->where('deleted_at', 0)->first();
if (empty($order)) {
UtilsService::getInstance()->errorThrow('订单不存在');
}
if ($userId > 0 && (int) $order['user_id'] !== $userId) {
UtilsService::getInstance()->errorThrow('无权操作该订单');
}
return $order->toArray();
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\OrderModel;
use App\Models\business\OrderPaymentModel;
use App\Models\business\WxUserModel;
/**
* 订单管理(后台)
*
* 建单、收款、发货的规则都在 OrderCoreService这里只做列表查询与管理员身份的透传。
*/
class OrderService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = OrderModel::class;
$this->selectField = [
'id', 'order_no', 'list_id', 'user_id', 'enterprise_id', 'total_amount', 'paid_amount',
'pay_type', 'pay_status', 'paid_at', 'delivery_type', 'receiver_name', 'receiver_phone',
'receiver_address', 'status', 'remark', 'created_at', 'updated_at',
];
$this->queryField = [
'order_no' => 'like',
'user_id' => '=',
'enterprise_id' => '=',
'status' => '=',
'pay_status' => '=',
'pay_type' => '=',
'delivery_type' => '=',
'receiver_phone' => 'like',
];
$this->with = ['user', 'enterprise'];
}
public function list(): array
{
$keyword = trim((string) request()->get('user_keyword', ''));
if ($keyword !== '') {
$userIds = WxUserModel::where('deleted_at', 0)
->where(function ($query) use ($keyword) {
$query->where('nick_name', 'like', '%' . $keyword . '%')
->orWhere('phone', 'like', '%' . $keyword . '%');
})->pluck('id')->all();
$this->whereIn = ['user_id', empty($userIds) ? [0] : $userIds];
}
$result = $this->getPageList();
$price = PriceService::getInstance();
foreach ($result['items'] as &$item) {
$item['user_name'] = $item['user']['nick_name'] ?? '';
$item['user_phone'] = $item['user']['phone'] ?? '';
$item['enterprise_name'] = $item['enterprise']['name'] ?? '';
$item['total_amount_text'] = $price->centsToYuan((int) $item['total_amount']);
$item['paid_amount_text'] = $price->centsToYuan((int) $item['paid_amount']);
}
unset($item);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'order_no'];
return $this->getOption();
}
public function detail($id): mixed
{
$order = OrderCoreService::getInstance()->detail((int) $id);
$price = PriceService::getInstance();
$order['total_amount_text'] = $price->centsToYuan((int) $order['total_amount']);
$order['paid_amount_text'] = $price->centsToYuan((int) $order['paid_amount']);
foreach ($order['items'] as &$item) {
$item['unit_price_text'] = $price->centsToYuan((int) $item['unit_price']);
$item['total_price_text'] = $price->centsToYuan((int) $item['total_price']);
}
unset($item);
return $order;
}
/**
* 后台代客建单
*/
public function create($params): mixed
{
$listId = (int) ($params['list_id'] ?? 0);
if ($listId <= 0) {
$this->utils->errorThrow('请选择清单');
}
return OrderCoreService::getInstance()->createFromList($listId, 0, $params);
}
/**
* 只允许改收件信息与备注:金额与状态必须走各自的业务入口
*/
public function update($id, $params): mixed
{
$allowed = array_intersect_key($params, array_flip([
'receiver_name', 'receiver_phone', 'receiver_address', 'remark', 'delivery_type',
]));
if (empty($allowed)) {
$this->utils->errorThrow('没有可修改的字段');
}
return $this->save($id, $allowed);
}
public function delete($ids): mixed
{
return $this->del($ids);
}
/**
* 审核转账凭证
*/
public function auditPayment(array $params): array
{
$paymentId = (int) ($params['payment_id'] ?? 0);
$pass = (int) ($params['status'] ?? 1) === 1;
return OrderCoreService::getInstance()->auditPayment(
$paymentId,
$pass ? OrderPaymentModel::STATUS_CONFIRMED : OrderPaymentModel::STATUS_REJECTED,
$this->userId,
(string) ($params['remark'] ?? '')
);
}
/**
* 发货
*/
public function ship(array $params): array
{
return OrderCoreService::getInstance()->ship(
(int) ($params['id'] ?? 0),
$params,
$this->userId
);
}
public function cancel(int $id): array
{
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_CANCELLED);
}
public function complete(int $id): array
{
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_DONE);
}
/**
* 某个用户的订单,用户详情模态框里用
*/
public function byUser(int $userId): array
{
$price = PriceService::getInstance();
$rows = OrderModel::where('user_id', $userId)->where('deleted_at', 0)->orderBy('id', 'desc')->get([
'id', 'order_no', 'total_amount', 'paid_amount', 'pay_status', 'status', 'created_at',
])->toArray();
foreach ($rows as &$row) {
$row['total_amount_text'] = $price->centsToYuan((int) $row['total_amount']);
}
unset($row);
return $rows;
}
/**
* 概览:各状态数量与金额,给列表页顶部的统计条
*/
public function stat(): array
{
$price = PriceService::getInstance();
$rows = OrderModel::where('deleted_at', 0)
->selectRaw('status, count(*) as total, sum(total_amount) as amount')
->groupBy('status')
->get();
$stat = ['total' => 0, 'amount' => 0, 'status' => []];
foreach ($rows as $row) {
$stat['total'] += (int) $row['total'];
$stat['amount'] += (int) $row['amount'];
$stat['status'][] = [
'status' => (int) $row['status'],
'count' => (int) $row['total'],
'amount' => (int) $row['amount'],
];
}
$stat['amount_text'] = $price->centsToYuan((int) $stat['amount']);
$stat['auditing'] = OrderPaymentModel::where('deleted_at', 0)
->where('status', OrderPaymentModel::STATUS_AUDITING)
->count();
return $stat;
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Service\business;
/**
* 价格可见性与倍率
*
* 报价单的 routine 列是一串用 @ 分隔的价格,每段要么是纯数字,要么是「名称:价格」,
* 乘倍率时只能乘价格段,把整段当数字乘会把名称吃掉。
* show_price 为假时整个价格数组换成 ['****'],并回 is_show_price=false 给前端。
*
* 这段逻辑原先只存在于 lgp-wx-api ProductService后台完全没有
* 于是后台看到的是原价、小程序看到的是倍率价,对账时无从复现。收在这里两边共用。
*/
class PriceService
{
private static mixed $_instance;
public const MASK = '****';
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 拆分 routine
*/
public function split(?string $routine): array
{
$routine = trim((string) $routine);
if ($routine === '') {
return [];
}
return array_values(array_filter(array_map('trim', explode('@', $routine)), fn ($v) => $v !== ''));
}
/**
* 按用户可见性与倍率格式化 routine
*
* @param bool $showPrice 是否可见价格
* @param int|float|string $multiplier 价格倍率
* @return array<int, string>
*/
public function formatRoutine(?string $routine, bool $showPrice, mixed $multiplier = 1): array
{
if (!$showPrice) {
return [self::MASK];
}
$items = $this->split($routine);
foreach ($items as &$item) {
$item = $this->formatPrice($item, $multiplier);
}
unset($item);
return $items;
}
/**
* 单段价格乘倍率。「名称:价格」只乘价格部分,兼容半角冒号与空格
*/
public function formatPrice(string $value, mixed $multiplier = 1): string
{
$multiplier = $this->normalizeMultiplier($multiplier);
try {
if (preg_match('/^[0-9.]+$/', $value)) {
return bcmul($value, $multiplier, 0);
}
$normalized = str_replace([':', ' '], ['', ''], $value);
$parts = explode('', $normalized);
if (array_key_exists(1, $parts) && preg_match('/^[0-9.]+$/', $parts[1])) {
$parts[1] = bcmul($parts[1], $multiplier, 0);
}
return implode('', $parts);
} catch (\Throwable) {
return $value;
}
}
/**
* 给一组报价单行套上价格规则,返回值里 routine 变成数组
*
* @param array $rows price_sheet
* @return array{rows: array, is_show_price: bool}
*/
public function applyToRows(array $rows, bool $showPrice, mixed $multiplier = 1): array
{
foreach ($rows as &$row) {
$row['routine'] = $this->formatRoutine($row['routine'] ?? '', $showPrice, $multiplier);
}
unset($row);
return ['rows' => $rows, 'is_show_price' => $showPrice];
}
/**
* 取某个材质的单价,返回「分」
*
* 下单要的是一个确定的数字,而 routine 是给人看的字符串(可能是 "1200"
* 也可能是 "布艺1200@皮艺1800")。这里按 materialKey 找对应段,
* 找不到就退回第一个能解析出数字的段;一个都没有返回 0,由调用方决定报错还是放过。
*
* 金额一律整数分:老库价格是整数元,乘完倍率再 ×100不引入浮点。
*/
public function resolveUnitPrice(?string $routine, string $materialKey = '', mixed $multiplier = 1): int
{
$items = $this->split($routine);
if (empty($items)) {
return 0;
}
$materialKey = trim($materialKey);
$fallback = 0;
foreach ($items as $item) {
$normalized = str_replace([':', ' '], ['', ''], $item);
$parts = explode('', $normalized);
$name = count($parts) > 1 ? $parts[0] : '';
$value = count($parts) > 1 ? $parts[1] : $parts[0];
if (!preg_match('/^[0-9.]+$/', $value)) {
continue;
}
$yuan = (int) bcmul($value, $this->normalizeMultiplier($multiplier), 0);
if ($materialKey !== '' && $materialKey !== 'routine' && $name === $materialKey) {
return $yuan * 100;
}
if ($fallback === 0) {
$fallback = $yuan * 100;
}
}
return $fallback;
}
/**
* 分转元字符串,仅用于展示与导出
*/
public function centsToYuan(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
/**
* 倍率兜底库里可能是空串、0 或负数,直接拿去 bcmul 会把价格清零
*/
private function normalizeMultiplier(mixed $multiplier): string
{
if (!is_numeric($multiplier) || (float) $multiplier <= 0) {
return '1';
}
return (string) $multiplier;
}
}

View File

@@ -0,0 +1,214 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\CatalogueModel;
use App\Models\business\PriceSheetModel;
use Exception;
use Illuminate\Support\Facades\DB;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 报价单(商品规格 + 常规价)
*
* 老接口靠 specification-1a / dimension-1b / routine-1b 这种动态键接收多行表单,
* 数量还得靠 count($params) 猜,多一个无关字段就会多循环一轮。
* 新接口收 rows 数组,并提供 saveRows 一次性覆盖某商品的全部规格行——抽屉里就是这个语义。
*/
class PriceSheetService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = PriceSheetModel::class;
$this->selectField = array_merge(
['id', 'catalogue_id', 'specification', 'dimension'],
PriceSheetModel::MATERIAL_FIELDS,
['status', 'created_at', 'updated_at']
);
$this->queryField = ['catalogue_id' => '=', 'specification' => 'like', 'status' => '='];
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
return $this->getPageList();
}
public function option(): mixed
{
$this->optionField = ['id', 'specification as name'];
return $this->getOption();
}
/**
* @throws Exception
*/
public function detail($id): mixed
{
return $this->getDetail($id);
}
/**
* 新增单行或多行rows
* @throws Exception
*/
public function create($params): mixed
{
$catalogueId = (int) ($params['catalogue_id'] ?? 0);
$this->assertCatalogue($catalogueId);
$rows = $this->normalizeRows($catalogueId, $params);
if (empty($rows)) {
$this->utils->errorThrow('请至少填写一行规格');
}
if (count($rows) === 1) {
return $this->insert($rows[0]);
}
return PriceSheetModel::insert($rows);
}
/**
* @throws Exception
*/
public function update($id, $params): mixed
{
unset($params['rows']);
return $this->save($id, $params);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
return $this->del(is_array($id) ? $id : [$id]);
}
/**
* @throws Exception
*/
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
/**
* 覆盖式保存某商品的全部规格行:带 id 的更新,不带的新增,界面上被删掉的软删。
* 一次事务完成,避免中途失败留下半套规格。
* @throws Exception
*/
public function saveRows(int $catalogueId, array $rows): bool
{
$this->assertCatalogue($catalogueId);
$now = time();
$keepIds = [];
DB::connection('business')->beginTransaction();
try {
foreach ($rows as $row) {
$payload = $this->pickRowFields($row);
if (trim((string) ($payload['specification'] ?? '')) === '') {
continue;
}
$payload['catalogue_id'] = $catalogueId;
$id = (int) ($row['id'] ?? 0);
if ($id > 0) {
$payload['updated_at'] = $now;
PriceSheetModel::where('id', $id)->where('catalogue_id', $catalogueId)->update($payload);
$keepIds[] = $id;
} else {
$payload['created_at'] = $now;
$keepIds[] = (int) PriceSheetModel::insertGetId($payload);
}
}
PriceSheetModel::where('catalogue_id', $catalogueId)
->where('deleted_at', 0)
->when(!empty($keepIds), fn ($q) => $q->whereNotIn('id', $keepIds))
->update(['deleted_at' => $now, 'updated_at' => $now]);
DB::connection('business')->commit();
} catch (Exception $e) {
DB::connection('business')->rollBack();
$this->utils->errorThrow($e->getMessage());
}
return true;
}
/**
* 某商品的全部规格行,供报价单抽屉回填
*/
public function rowsOf(int $catalogueId): array
{
return PriceSheetModel::where('catalogue_id', $catalogueId)
->where('deleted_at', 0)
->orderBy('id')
->get($this->selectField)
->toArray();
}
/**
* 兼容单行字段与 rows 数组两种入参
*/
private function normalizeRows(int $catalogueId, array $params): array
{
$now = time();
$source = [];
if (!empty($params['rows']) && is_array($params['rows'])) {
$source = $params['rows'];
} elseif (trim((string) ($params['specification'] ?? '')) !== '') {
$source = [$params];
}
$rows = [];
foreach ($source as $row) {
if (!is_array($row)) {
continue;
}
$payload = $this->pickRowFields($row);
if (trim((string) ($payload['specification'] ?? '')) === '') {
continue;
}
$payload['catalogue_id'] = $catalogueId;
$payload['created_at'] = $now;
$rows[] = $payload;
}
return $rows;
}
/**
* 只取表里真实存在的列,把前端多传的字段挡在外面
*/
private function pickRowFields(array $row): array
{
$allowed = array_merge(['specification', 'dimension'], PriceSheetModel::MATERIAL_FIELDS);
$payload = [];
foreach ($allowed as $field) {
if (array_key_exists($field, $row)) {
$payload[$field] = is_scalar($row[$field]) ? (string) $row[$field] : '';
}
}
return $payload;
}
/**
* @throws Exception
*/
private function assertCatalogue(int $catalogueId): void
{
if ($catalogueId <= 0) {
$this->utils->errorThrow('请选择商品');
}
$exists = CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists();
if (!$exists) {
$this->utils->errorThrow('商品不存在');
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Service\business;
use Illuminate\Support\Facades\DB;
/**
* 业务单号生成(清单 QD_ / 订单 DD_
*
* 随机串剔除 0/O/1/I/L 这些肉眼分不清的字符——单号会被打印在纸质报价单上人工抄录。
* 唯一索引兜底,撞号就重试;重试仍失败宁可报错,也不要为了成功而降级成可能重复的号。
*/
class SerialNoService
{
private static mixed $_instance;
private const ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
public const PREFIX_LIST = 'QD_';
public const PREFIX_ORDER = 'DD_';
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 生成一个库内不存在的单号
*
* @param string $prefix self::PREFIX_*
* @param string $table business 连接下的表名(不带前缀)
* @param string $column 单号字段
*/
public function generate(string $prefix, string $table, string $column, int $tries = 10): string
{
$day = date('Ymd');
for ($i = 0; $i < $tries; $i++) {
$no = $prefix . $day . $this->randomPart(6);
$exists = DB::connection('business')->table($table)->where($column, $no)->exists();
if (!$exists) {
return $no;
}
}
// 连续撞 10 次说明随机源或并发量出了问题,静默返回可能重复的号比抛错危险得多
throw new \RuntimeException('单号生成失败,请重试');
}
private function randomPart(int $length): string
{
$max = strlen(self::ALPHABET) - 1;
$out = '';
for ($i = 0; $i < $length; $i++) {
$out .= self::ALPHABET[random_int(0, $max)];
}
return $out;
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Service\business;
/**
* 把预设的紧凑规格展开成完整设计令牌
*
* 20 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 JSON 手抄跑偏,
* 也让「再加一套模板」变成加十几行配置而不是重写一份主题。
*/
class WxTemplatePresetService
{
private static mixed $_instance;
/**
* 字体与字号梯度档位(单位 rpx小程序习惯用 rpx
*/
private const SCALES = [
'serif-elegant' => [
'family' => 'Songti SC, Noto Serif SC, serif',
'family_title' => 'Songti SC, Noto Serif SC, serif',
'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx',
'size_lg' => '34rpx', 'size_xl' => '42rpx', 'size_title' => '52rpx',
'weight_normal' => '400', 'weight_bold' => '600',
'line_height' => '1.7', 'letter_spacing' => '1rpx',
],
'serif-book' => [
'family' => 'Noto Serif SC, Georgia, serif',
'family_title' => 'Noto Serif SC, Georgia, serif',
'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx',
'size_lg' => '34rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx',
'weight_normal' => '400', 'weight_bold' => '700',
'line_height' => '1.8', 'letter_spacing' => '0',
],
'sans-refined' => [
'family' => 'PingFang SC, HarmonyOS Sans, sans-serif',
'family_title' => 'PingFang SC, HarmonyOS Sans, sans-serif',
'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx',
'size_lg' => '32rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx',
'weight_normal' => '400', 'weight_bold' => '600',
'line_height' => '1.6', 'letter_spacing' => '0',
],
'sans-compact' => [
'family' => 'PingFang SC, Roboto, sans-serif',
'family_title' => 'PingFang SC, Roboto, sans-serif',
'size_xs' => '18rpx', 'size_sm' => '22rpx', 'size_md' => '26rpx',
'size_lg' => '30rpx', 'size_xl' => '36rpx', 'size_title' => '42rpx',
'weight_normal' => '400', 'weight_bold' => '700',
'line_height' => '1.5', 'letter_spacing' => '0',
],
'sans-wide' => [
'family' => 'PingFang SC, Inter, sans-serif',
'family_title' => 'PingFang SC, Inter, sans-serif',
'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx',
'size_lg' => '36rpx', 'size_xl' => '44rpx', 'size_title' => '56rpx',
'weight_normal' => '400', 'weight_bold' => '700',
'line_height' => '1.6', 'letter_spacing' => '2rpx',
],
];
private const RADIUS = [
'none' => ['none' => '0', 'sm' => '0', 'md' => '0', 'lg' => '0', 'xl' => '0', 'pill' => '0'],
'sharp' => ['none' => '0', 'sm' => '2rpx', 'md' => '4rpx', 'lg' => '8rpx', 'xl' => '12rpx', 'pill' => '999rpx'],
'soft' => ['none' => '0', 'sm' => '8rpx', 'md' => '12rpx', 'lg' => '20rpx', 'xl' => '28rpx', 'pill' => '999rpx'],
'round' => ['none' => '0', 'sm' => '12rpx', 'md' => '20rpx', 'lg' => '32rpx', 'xl' => '44rpx', 'pill' => '999rpx'],
'pill' => ['none' => '0', 'sm' => '20rpx', 'md' => '32rpx', 'lg' => '48rpx', 'xl' => '64rpx', 'pill' => '999rpx'],
];
private const SHADOW = [
'flat' => ['none' => 'none', 'sm' => 'none', 'md' => 'none', 'lg' => 'none'],
'airy' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.04)', 'md' => '0 8rpx 24rpx rgba(0,0,0,0.06)', 'lg' => '0 16rpx 48rpx rgba(0,0,0,0.08)'],
'soft' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.06)', 'md' => '0 8rpx 20rpx rgba(0,0,0,0.10)', 'lg' => '0 16rpx 40rpx rgba(0,0,0,0.14)'],
'deep' => ['none' => 'none', 'sm' => '0 4rpx 12rpx rgba(0,0,0,0.16)', 'md' => '0 12rpx 32rpx rgba(0,0,0,0.24)', 'lg' => '0 24rpx 64rpx rgba(0,0,0,0.32)'],
];
private const MOTION = [
'snappy' => ['fast' => '120ms', 'base' => '180ms', 'slow' => '260ms', 'easing' => 'cubic-bezier(0.4,0,0.2,1)', 'easing_in' => 'cubic-bezier(0.4,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.2,1)'],
'gentle' => ['fast' => '180ms', 'base' => '260ms', 'slow' => '400ms', 'easing' => 'cubic-bezier(0.25,0.1,0.25,1)', 'easing_in' => 'cubic-bezier(0.42,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.58,1)'],
'silk' => ['fast' => '220ms', 'base' => '320ms', 'slow' => '520ms', 'easing' => 'cubic-bezier(0.22,1,0.36,1)', 'easing_in' => 'cubic-bezier(0.55,0,1,0.45)', 'easing_out' => 'cubic-bezier(0.16,1,0.3,1)'],
'bouncy' => ['fast' => '160ms', 'base' => '280ms', 'slow' => '460ms', 'easing' => 'cubic-bezier(0.34,1.56,0.64,1)', 'easing_in' => 'cubic-bezier(0.36,0,0.66,-0.56)', 'easing_out' => 'cubic-bezier(0.34,1.56,0.64,1)'],
];
private const SPACE = [
'xxs' => '4rpx', 'xs' => '8rpx', 'sm' => '16rpx',
'md' => '24rpx', 'lg' => '32rpx', 'xl' => '48rpx', 'page' => '32rpx',
];
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 全部预设,已展开成入库可用的行
*
* @return array<int, array{code:string,name:string,style_tag:string,tokens:array,layout:array}>
*/
public function all(): array
{
$rows = [];
foreach ((array) config('wx_templates', []) as $index => $preset) {
$rows[] = [
'code' => (string) $preset['code'],
'name' => (string) $preset['name'],
'style_tag' => (string) ($preset['style_tag'] ?? ''),
'sort' => $index,
'tokens' => $this->expandTokens($preset),
'layout' => (array) ($preset['layout'] ?? []),
];
}
return $rows;
}
/**
* 展开单个预设的令牌
*/
public function expandTokens(array $preset): array
{
$palette = (array) ($preset['palette'] ?? []);
$primary = (string) ($palette['primary'] ?? '#B08D57');
$isDark = $this->isDark((string) ($palette['bg'] ?? '#FFFFFF'));
return [
'color' => [
'primary' => $primary,
'primary_soft' => $this->mix($primary, $isDark ? '#000000' : '#FFFFFF', 0.7),
'primary_strong' => $this->mix($primary, '#000000', 0.2),
'accent' => (string) ($palette['accent'] ?? $primary),
'bg' => (string) ($palette['bg'] ?? '#FFFFFF'),
'bg_soft' => $this->mix((string) ($palette['bg'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.04),
'surface' => (string) ($palette['surface'] ?? '#FFFFFF'),
'surface_soft' => $this->mix((string) ($palette['surface'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.03),
'text' => (string) ($palette['text'] ?? '#18181B'),
'text_soft' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.25),
'text_muted' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.5),
'border' => (string) ($palette['border'] ?? '#E4E4E7'),
// 价格用主色的强化版,保证在浅色与深色底上都够醒目
'price' => $this->mix($primary, '#000000', $isDark ? 0 : 0.12),
'success' => '#16A34A',
'warning' => '#D97706',
'danger' => '#DC2626',
'mask' => $isDark ? 'rgba(0,0,0,0.72)' : 'rgba(0,0,0,0.45)',
],
'font' => self::SCALES[$preset['scale'] ?? 'sans-refined'] ?? self::SCALES['sans-refined'],
'radius' => self::RADIUS[$preset['radius'] ?? 'soft'] ?? self::RADIUS['soft'],
'shadow' => self::SHADOW[$preset['shadow'] ?? 'soft'] ?? self::SHADOW['soft'],
'space' => self::SPACE,
'motion' => self::MOTION[$preset['motion'] ?? 'gentle'] ?? self::MOTION['gentle'],
];
}
/**
* 两色按比例混合,用来派生 soft / strong 变体
*
* @param float $ratio target 的占比
*/
private function mix(string $color, string $target, float $ratio): string
{
[$r1, $g1, $b1] = $this->toRgb($color);
[$r2, $g2, $b2] = $this->toRgb($target);
$ratio = max(0, min(1, $ratio));
return sprintf(
'#%02X%02X%02X',
(int) round($r1 + ($r2 - $r1) * $ratio),
(int) round($g1 + ($g2 - $g1) * $ratio),
(int) round($b1 + ($b2 - $b1) * $ratio)
);
}
private function isDark(string $color): bool
{
[$r, $g, $b] = $this->toRgb($color);
// 感知亮度,低于 128 视作深色底
return (0.299 * $r + 0.587 * $g + 0.114 * $b) < 128;
}
/**
* @return array{0:int,1:int,2:int}
*/
private function toRgb(string $color): array
{
$hex = ltrim(trim($color), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || !ctype_xdigit($hex)) {
return [255, 255, 255];
}
return [
(int) hexdec(substr($hex, 0, 2)),
(int) hexdec(substr($hex, 2, 2)),
(int) hexdec(substr($hex, 4, 2)),
];
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Service\business;
use App\Service\common\UtilsService;
/**
* 装修模板 JSON 的白名单校验
*
* 导入的 JSON 会被小程序当成 CSS 变量注入根节点,一份坏 JSON 能把线上小程序打成白屏,
* 所以入库前必须过白名单:键名不在表里的丢弃,值必须是安全的短字符串。
* 这里只允许「值」,不允许任何 CSS 语句片段分号、url()、表达式一律拒绝)。
*/
class WxTemplateSchemaService
{
private static mixed $_instance;
/**
* 允许的令牌键,分组 => 键名清单
*/
public const TOKEN_SCHEMA = [
'color' => [
'primary', 'primary_soft', 'primary_strong', 'accent', 'bg', 'bg_soft',
'surface', 'surface_soft', 'text', 'text_soft', 'text_muted', 'border',
'price', 'success', 'warning', 'danger', 'mask',
],
'font' => [
'family', 'family_title', 'size_xs', 'size_sm', 'size_md', 'size_lg',
'size_xl', 'size_title', 'weight_normal', 'weight_bold', 'line_height', 'letter_spacing',
],
'radius' => ['none', 'sm', 'md', 'lg', 'xl', 'pill'],
'shadow' => ['none', 'sm', 'md', 'lg'],
'space' => ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'page'],
'motion' => ['fast', 'base', 'slow', 'easing', 'easing_in', 'easing_out'],
];
/**
* 允许的布局键与可选值
*/
public const LAYOUT_SCHEMA = [
'home' => [
'hero' => ['banner', 'carousel', 'split', 'fullscreen'],
'category' => ['grid', 'scroll', 'card', 'sidebar'],
'product' => ['waterfall', 'list', 'grid', 'magazine'],
],
'product' => [
'gallery' => ['swiper', 'stack', 'fullbleed'],
'price' => ['inline', 'card', 'sticky'],
'action' => ['fixed', 'inline'],
],
'list' => [
'style' => ['card', 'table', 'timeline'],
],
'mine' => [
'header' => ['gradient', 'image', 'plain'],
'menu' => ['grid', 'list'],
],
'effect' => [
'transition' => ['fade', 'slide', 'zoom', 'none'],
'skeleton' => ['shimmer', 'pulse', 'none'],
],
];
/**
* 危险片段:出现即拒绝整个值
*/
private const FORBIDDEN = ['<', '>', ';', '{', '}', 'url(', 'expression', 'javascript:', 'import'];
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 清洗令牌:只保留白名单键,值必须安全
*
* @param mixed $tokens 数组或 JSON 字符串
* @param bool $strict true 时遇到非法值直接报错导入场景false 时静默丢弃
*/
public function sanitizeTokens(mixed $tokens, bool $strict = false): array
{
$tokens = $this->toArray($tokens);
$clean = [];
foreach (self::TOKEN_SCHEMA as $group => $keys) {
$source = $tokens[$group] ?? [];
if (!is_array($source)) {
continue;
}
foreach ($keys as $key) {
if (!array_key_exists($key, $source)) {
continue;
}
$value = $source[$key];
if (!$this->isSafeValue($value)) {
if ($strict) {
UtilsService::getInstance()->errorThrow("模板令牌 {$group}.{$key} 的值不合法");
}
continue;
}
$clean[$group][$key] = (string) $value;
}
}
return $clean;
}
/**
* 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项
*/
public function sanitizeLayout(mixed $layout, bool $strict = false): array
{
$layout = $this->toArray($layout);
$clean = [];
foreach (self::LAYOUT_SCHEMA as $page => $options) {
$source = $layout[$page] ?? [];
if (!is_array($source)) {
continue;
}
foreach ($options as $key => $allowed) {
if (!array_key_exists($key, $source)) {
continue;
}
$value = (string) $source[$key];
if (!in_array($value, $allowed, true)) {
if ($strict) {
UtilsService::getInstance()->errorThrow("模板布局 {$page}.{$key} 只能是:" . implode('/', $allowed));
}
$value = $allowed[0];
}
$clean[$page][$key] = $value;
}
}
return $clean;
}
/**
* 小程序侧要的扁平 CSS 变量表:--color-primary 这种
*/
public function toCssVariables(array $tokens): array
{
$vars = [];
foreach ($tokens as $group => $items) {
if (!is_array($items)) {
continue;
}
foreach ($items as $key => $value) {
$vars['--' . str_replace('_', '-', $group . '-' . $key)] = $value;
}
}
return $vars;
}
private function isSafeValue(mixed $value): bool
{
if (is_int($value) || is_float($value)) {
return true;
}
if (!is_string($value)) {
return false;
}
$value = trim($value);
if ($value === '' || mb_strlen($value) > 64) {
return false;
}
foreach (self::FORBIDDEN as $needle) {
if (stripos($value, $needle) !== false) {
return false;
}
}
return true;
}
private function toArray(mixed $value): array
{
if (is_string($value)) {
$value = json_decode($value, true);
if (!is_array($value)) {
UtilsService::getInstance()->errorThrow('模板 JSON 解析失败');
}
}
return is_array($value) ? $value : [];
}
}

View File

@@ -0,0 +1,262 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\WxTemplateModel;
use Illuminate\Support\Facades\DB;
/**
* 小程序装修模板管理(后台)
*/
class WxTemplateService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = WxTemplateModel::class;
$this->selectField = [
'id', 'name', 'code', 'preview', 'style_tag', 'is_default', 'status',
'app_code', 'version', 'sort', 'created_at', 'updated_at',
];
$this->queryField = [
'name' => 'like',
'code' => 'like',
'style_tag' => '=',
'app_code' => '=',
'status' => '=',
];
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
}
public function list(): array
{
$result = $this->getPageList();
// 列表不回完整 tokens体积大但卡片预览需要色板与布局摘要
$ids = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['id'] ?? 0),
$result['items'] ?? []
)));
if ($ids === []) {
return $result;
}
$extras = WxTemplateModel::whereIn('id', $ids)
->get(['id', 'tokens', 'layout'])
->keyBy('id');
foreach ($result['items'] as &$item) {
$extra = $extras[(int) $item['id']] ?? null;
$tokens = is_array($extra?->tokens) ? $extra->tokens : [];
$layout = is_array($extra?->layout) ? $extra->layout : [];
$color = is_array($tokens['color'] ?? null) ? $tokens['color'] : [];
$home = is_array($layout['home'] ?? null) ? $layout['home'] : [];
$item['swatch'] = [
'primary' => (string) ($color['primary'] ?? '#B08D57'),
'accent' => (string) ($color['accent'] ?? ($color['primary'] ?? '#B08D57')),
'bg' => (string) ($color['bg'] ?? '#FAFAF9'),
'surface' => (string) ($color['surface'] ?? '#FFFFFF'),
'text' => (string) ($color['text'] ?? '#18181B'),
'border' => (string) ($color['border'] ?? '#E4E4E7'),
];
$item['layout_hint'] = [
'hero' => (string) ($home['hero'] ?? 'banner'),
'category' => (string) ($home['category'] ?? 'grid'),
'product' => (string) ($home['product'] ?? 'grid'),
];
}
unset($item);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'name', 'code', 'style_tag'];
return $this->getOption();
}
public function detail($id): mixed
{
// 详情是单条记录,没有 list 的字段裁剪压力;
// tokens/layout 必须回显(编辑弹窗回填要用),不能用默认 selectField它把这两个字段排除了
$saved = $this->selectField;
$this->selectField = ['*'];
try {
return $this->getDetail($id);
} finally {
$this->selectField = $saved;
}
}
public function create($params): mixed
{
$params = $this->normalize($params);
return $this->insert($params);
}
public function update($id, $params): mixed
{
$params = $this->normalize($params, (int) $id);
return $this->save($id, $params);
}
public function delete($ids): mixed
{
// 默认模板被删掉小程序就没样式可用了,必须先改默认再删
$hasDefault = WxTemplateModel::whereIn('id', (array) $ids)->where('is_default', 1)->exists();
if ($hasDefault) {
$this->utils->errorThrow('默认模板不能删除,请先把其他模板设为默认');
}
return $this->del($ids);
}
public function status($id, $status): mixed
{
return $this->save($id, ['status' => (int) $status]);
}
/**
* 设为默认(同一 app_code 下只能有一个默认)
* 同时把 code 写入 nl_wx_app.template_code保证小程序 current() 优先命中
*/
public function setDefault(int $id): bool
{
$template = WxTemplateModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($template)) {
$this->utils->errorThrow('模板不存在');
}
DB::connection('business')->transaction(function () use ($template, $id) {
WxTemplateModel::where('app_code', $template['app_code'])
->where('id', '!=', $id)
->update(['is_default' => 0, 'updated_at' => time()]);
WxTemplateModel::where('id', $id)->update([
'is_default' => 1,
'status' => 0,
'updated_at' => time(),
]);
});
// 系统库 nl_wx_app空 app_code 的全局模板同步到全部启用应用;有品牌则只同步该品牌
$appQuery = \App\Models\WxAppModel::where('deleted_at', 0)->where('status', 0);
$appCode = trim((string) ($template['app_code'] ?? ''));
if ($appCode !== '') {
$appQuery->where('code', $appCode);
}
$appQuery->update([
'template_code' => (string) $template['code'],
'updated_at' => time(),
]);
return true;
}
/**
* 导出:给出可直接再导入的 JSON
*/
public function export(array $ids): array
{
$rows = WxTemplateModel::whereIn('id', $ids)->where('deleted_at', 0)->get([
'name', 'code', 'preview', 'style_tag', 'tokens', 'layout', 'app_code', 'version',
]);
return [
'version' => 1,
'exported_at' => date('Y-m-d H:i:s'),
'templates' => $rows->toArray(),
];
}
/**
* 导入:整包校验通过才落库
*
* 单条不合法就整包拒绝,不做「部分成功」——一半新一半旧的模板库更难排查。
*/
public function import(array $payload, bool $overwrite = false): array
{
$templates = $payload['templates'] ?? $payload;
if (!is_array($templates) || empty($templates)) {
$this->utils->errorThrow('导入内容为空');
}
$schema = WxTemplateSchemaService::getInstance();
$rows = [];
foreach ($templates as $index => $item) {
$code = trim((string) ($item['code'] ?? ''));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
$this->utils->errorThrow('第 ' . ($index + 1) . ' 个模板缺少 code 或 name');
}
$rows[] = [
'code' => $code,
'name' => $name,
'preview' => (string) ($item['preview'] ?? ''),
'style_tag' => (string) ($item['style_tag'] ?? ''),
'app_code' => (string) ($item['app_code'] ?? ''),
'tokens' => json_encode($schema->sanitizeTokens($item['tokens'] ?? [], true), JSON_UNESCAPED_UNICODE),
'layout' => json_encode($schema->sanitizeLayout($item['layout'] ?? [], true), JSON_UNESCAPED_UNICODE),
'version' => max(1, (int) ($item['version'] ?? 1)),
];
}
$inserted = 0;
$updated = 0;
$skipped = 0;
DB::connection('business')->transaction(function () use ($rows, $overwrite, &$inserted, &$updated, &$skipped) {
foreach ($rows as $row) {
$exists = WxTemplateModel::where('code', $row['code'])
->where('app_code', $row['app_code'])
->first();
if (!empty($exists)) {
if (!$overwrite) {
$skipped++;
continue;
}
$row['version'] = (int) $exists['version'] + 1;
$row['updated_at'] = time();
WxTemplateModel::where('id', $exists['id'])->update($row);
$updated++;
continue;
}
$row['created_at'] = time();
WxTemplateModel::insert($row);
$inserted++;
}
});
return ['inserted' => $inserted, 'updated' => $updated, 'skipped' => $skipped];
}
/**
* 用内置预设初始化模板库20 套)
*/
public function initPresets(bool $overwrite = false): array
{
$presets = WxTemplatePresetService::getInstance()->all();
$result = $this->import(['templates' => $presets], $overwrite);
// 一套默认都没有的话,把第一套轻奢设为默认
if (!WxTemplateModel::where('deleted_at', 0)->where('is_default', 1)->exists()) {
$first = WxTemplateModel::where('deleted_at', 0)->orderBy('sort', 'asc')->first();
if (!empty($first)) {
$this->setDefault((int) $first['id']);
}
}
return $result;
}
/**
* 入库前清洗code 在同一品牌下唯一
*/
private function normalize(array $params, int $excludeId = 0): array
{
$schema = WxTemplateSchemaService::getInstance();
if (array_key_exists('tokens', $params)) {
$params['tokens'] = json_encode($schema->sanitizeTokens($params['tokens'], true), JSON_UNESCAPED_UNICODE);
}
if (array_key_exists('layout', $params)) {
$params['layout'] = json_encode($schema->sanitizeLayout($params['layout'], true), JSON_UNESCAPED_UNICODE);
}
if (!empty($params['code'])) {
$duplicate = WxTemplateModel::where('code', $params['code'])
->where('app_code', (string) ($params['app_code'] ?? ''))
->when($excludeId > 0, fn ($query) => $query->where('id', '!=', $excludeId))
->exists();
if ($duplicate) {
$this->utils->errorThrow('模板标识已存在');
}
}
return $params;
}
}

View File

@@ -0,0 +1,257 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\EnterpriseModel;
use App\Models\business\WxTemplateModel;
use App\Models\business\WxUserModel;
use App\Service\common\MediaUrlService;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 微信用户管理
*
* 三件事认代理商is_p、给代理商设价格倍率price_number、把用户绑到企业
* 另可给经销商绑定专属装修模板template_code
* session_key 属于服务端凭据selectField 里绝不带上。
*/
class WxUserService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = WxUserModel::class;
$this->selectField = [
'id', 'open_id', 'phone', 'nick_name', 'avatar',
'show_price', 'is_p', 'pid', 'enterprise_id', 'price_number', 'template_code',
'created_at', 'updated_at',
];
$this->queryField = [
'nick_name' => 'like',
'phone' => 'like',
'is_p' => '=',
'show_price' => '=',
'enterprise_id' => '=',
];
$this->media = MediaUrlService::getInstance();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
$this->with = ['enterprise'];
$result = $this->getPageList();
foreach ($result['items'] as &$item) {
$item['enterprise_name'] = $item['enterprise']['name'] ?? '';
unset($item['enterprise']);
$item['avatar'] = $this->media->toPublic($item['avatar'] ?? '');
}
unset($item);
return $result;
}
public function option(): mixed
{
$this->optionField = ['id', 'nick_name as name', 'phone'];
return $this->getOption();
}
/**
* 详情:带所属企业、上级代理商与下级数量
* @throws Exception
*/
public function detail($id): mixed
{
$this->with = ['enterprise'];
$info = $this->getDetail($id);
$info->avatar = $this->media->toPublic($info->avatar);
$info->enterprise_name = $info->enterprise->name ?? '';
$info->parent_name = $info->pid > 0
? (string) (WxUserModel::where('id', $info->pid)->value('nick_name') ?? '')
: '';
$info->child_count = WxUserModel::where('pid', $info->id)->where('deleted_at', 0)->count();
return $info;
}
/**
* 后台不创建微信用户(用户只能由小程序授权登录产生)
* @throws Exception
*/
public function create($params): mixed
{
return $this->utils->errorThrow('微信用户由小程序授权登录产生,后台不支持新建');
}
/**
* 后台只允许改这几项,避免把 open_id 之类的身份字段改花
* @throws Exception
*/
public function update($id, $params): mixed
{
$allowed = ['nick_name', 'phone', 'enterprise_id', 'show_price', 'price_number', 'is_p', 'template_code'];
$payload = array_intersect_key($params, array_flip($allowed));
if (empty($payload)) {
$this->utils->errorThrow('没有可更新的字段');
}
if (array_key_exists('price_number', $payload)) {
$payload['price_number'] = $this->normalizeMultiplier($payload['price_number']);
}
if (array_key_exists('template_code', $payload)) {
$payload['template_code'] = trim((string) $payload['template_code']);
}
return $this->save($id, $payload);
}
/**
* @throws Exception
*/
public function delete($id): mixed
{
return $this->del(is_array($id) ? $id : [$id]);
}
/**
* 切换代理商身份
*
* 老接口是「翻转」语义(不传目标值,后端读当前值取反),前端的开关点快了就会和后端打反。
* 这里改成显式传 is_p同时保留不传时的翻转以兼容老调用。
* @throws Exception
*/
public function updateUserIsP($id, mixed $isP = null): bool
{
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($user)) {
$this->utils->errorThrow('用户不存在');
}
$target = $isP === null || $isP === ''
? ((int) $user->is_p === 1 ? 0 : 1)
: (int) $isP;
// 认成代理商就默认可见价格、倍率归 1取消代理商则收回价格可见性并清空专属模板
$data = $target === 1
? ['is_p' => 1, 'show_price' => 1, 'price_number' => 1]
: ['is_p' => 0, 'show_price' => 0, 'price_number' => 1, 'template_code' => ''];
$data['updated_at'] = time();
WxUserModel::where('id', $id)->update($data);
return true;
}
/**
* 给经销商绑定专属装修模板(空字符串=跟随品牌默认)
* @throws Exception
*/
public function bindTemplate($id, mixed $templateCode): bool
{
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($user)) {
$this->utils->errorThrow('用户不存在');
}
if ((int) $user->is_p !== 1) {
$this->utils->errorThrow('仅经销商可绑定专属模板');
}
$code = trim((string) $templateCode);
if ($code !== '') {
$exists = WxTemplateModel::where('code', $code)
->where('deleted_at', 0)
->where('status', 0)
->exists();
if (!$exists) {
$this->utils->errorThrow('模板不存在或已停用');
}
}
WxUserModel::where('id', $id)->update([
'template_code' => $code,
'updated_at' => time(),
]);
return true;
}
/**
* 设置价格倍率(同时打开价格可见)
* @throws Exception
*/
public function updateShowPrice($id, mixed $number): bool
{
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($user)) {
$this->utils->errorThrow('用户不存在');
}
$multiplier = $this->normalizeMultiplier($number);
WxUserModel::where('id', $id)->update([
'show_price' => 1,
'price_number' => $multiplier,
'updated_at' => time(),
]);
return true;
}
/**
* 单独控制价格可见性(不动倍率)
* @throws Exception
*/
public function updateShowPriceStatus($id, mixed $showPrice): bool
{
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($user)) {
$this->utils->errorThrow('用户不存在');
}
WxUserModel::where('id', $id)->update([
'show_price' => (int) $showPrice === 1 ? 1 : 0,
'updated_at' => time(),
]);
return true;
}
/**
* 绑定企业
* @throws Exception
*/
public function bindUser($id, $enterpriseId): bool
{
$enterpriseId = (int) $enterpriseId;
if (!WxUserModel::where('id', $id)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('用户不存在');
}
if ($enterpriseId > 0 && !EnterpriseModel::where('id', $enterpriseId)->where('deleted_at', 0)->exists()) {
$this->utils->errorThrow('企业不存在');
}
WxUserModel::where('id', $id)->update([
'enterprise_id' => $enterpriseId,
'updated_at' => time(),
]);
return true;
}
/**
* 该代理商名下的下级用户
*/
public function children(int $id): array
{
return WxUserModel::where('pid', $id)
->where('deleted_at', 0)
->orderBy('id', 'desc')
->get(['id', 'nick_name', 'phone', 'price_number', 'show_price', 'created_at'])
->toArray();
}
/**
* 倍率兜底0 或负数会把价格直接乘成 0
*/
private function normalizeMultiplier(mixed $number): string
{
if (!is_numeric($number) || (float) $number <= 0) {
return '1';
}
return (string) $number;
}
}