Files

428 lines
16 KiB
PHP
Raw Permalink Normal View History

<?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;
2026-08-20 19:16:49 +08:00
use Illuminate\Support\Facades\DB;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 商品图册
*/
class CatalogueService extends BaseService
{
private MediaUrlService $media;
public function __construct()
{
parent::__construct();
$this->model = CatalogueModel::class;
2026-08-20 19:16:49 +08:00
$this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'video', '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'] ?? '');
2026-08-20 19:16:49 +08:00
$item['video'] = $this->media->toPublic($item['video'] ?? '');
$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);
2026-08-20 19:16:49 +08:00
$info->video = $this->media->toPublic($info->video);
$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]);
}
2026-08-20 19:16:49 +08:00
/**
* 导出商品 + 关联报价单两个工作表xlsx 由前端 ExcelJS 美化
*/
public function exportExcel(): array
{
$rows = CatalogueModel::where('deleted_at', 0)->orderByDesc('id')->get([
'id', 'title', 'identifier', 'alias', 'category_id', 'price', 'status',
])->toArray();
$byId = [];
foreach ($rows as &$row) {
$row['status'] = (int) ($row['status'] ?? 0) === 0 ? '上架' : '下架';
$byId[(int) $row['id']] = $row;
}
unset($row);
$ids = array_keys($byId);
$priceRows = [];
if (!empty($ids)) {
$sheets = PriceSheetModel::where('deleted_at', 0)
->whereIn('catalogue_id', $ids)
->orderBy('catalogue_id')
->orderBy('id')
->get(['id', 'catalogue_id', 'specification', 'dimension', 'routine', 'stock', 'status'])
->toArray();
foreach ($sheets as $sheet) {
$cat = $byId[(int) $sheet['catalogue_id']] ?? [];
$priceRows[] = [
'id' => $sheet['id'],
'catalogue_id' => $sheet['catalogue_id'],
'identifier' => $cat['identifier'] ?? '',
'title' => $cat['title'] ?? '',
'specification' => $sheet['specification'] ?? '',
'dimension' => $sheet['dimension'] ?? '',
'routine' => $sheet['routine'] ?? '',
'stock' => (int) ($sheet['stock'] ?? -1),
'status' => (int) ($sheet['status'] ?? 0) === 0 ? '启用' : '禁用',
];
}
}
return [
'filename' => 'catalogue.xlsx',
'title' => '商品图册',
'sheets' => [
[
'title' => '商品图册',
'sheet' => '商品',
'columns' => [
['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'],
['key' => 'title', 'header' => '商品名称', 'width' => 28],
['key' => 'identifier', 'header' => '商品编号', 'width' => 16],
['key' => 'alias', 'header' => '别名', 'width' => 24],
['key' => 'category_id', 'header' => '分类ID', 'width' => 12, 'align' => 'center'],
['key' => 'price', 'header' => '参考价', 'width' => 14, 'align' => 'right'],
['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'],
],
'rows' => $rows,
],
[
'title' => '关联报价单',
'sheet' => '报价单',
'subtitle' => '按商品编号回写;库存 -1 表示不限',
'columns' => [
['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'],
['key' => 'catalogue_id', 'header' => '商品ID', 'width' => 12, 'align' => 'center'],
['key' => 'identifier', 'header' => '商品编号', 'width' => 16],
['key' => 'title', 'header' => '商品名称', 'width' => 24],
['key' => 'specification', 'header' => '规格', 'width' => 20],
['key' => 'dimension', 'header' => '尺寸', 'width' => 16],
['key' => 'routine', 'header' => '常规价', 'width' => 14, 'align' => 'right'],
['key' => 'stock', 'header' => '库存', 'width' => 10, 'align' => 'center'],
['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'],
],
'rows' => $priceRows,
],
],
];
}
/**
* 导入商品 + 报价单:先落商品,再按编号挂规格
*/
public function importRows(array $rows, array $priceSheets = []): array
{
$created = 0;
$updated = 0;
$priceCreated = 0;
$priceUpdated = 0;
$now = time();
DB::connection('business')->beginTransaction();
try {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$identifier = trim((string) ($row['identifier'] ?? $row['商品编号'] ?? ''));
$title = trim((string) ($row['title'] ?? $row['商品名称'] ?? ''));
if ($title === '') {
continue;
}
$payload = [
'title' => $title,
'identifier' => $identifier,
'alias' => (string) ($row['alias'] ?? $row['别名'] ?? ''),
'category_id' => (int) ($row['category_id'] ?? $row['分类ID'] ?? 0),
'price' => (string) ($row['price'] ?? $row['参考价'] ?? ''),
'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0),
'updated_at' => $now,
];
$exists = $identifier !== ''
? CatalogueModel::where('identifier', $identifier)->where('deleted_at', 0)->first()
: null;
if (!empty($exists)) {
CatalogueModel::where('id', $exists['id'])->update($payload);
$updated++;
} else {
$payload['created_at'] = $now;
CatalogueModel::insert($payload);
$created++;
}
}
[$priceCreated, $priceUpdated] = $this->importPriceSheets($priceSheets, $now);
DB::connection('business')->commit();
} catch (Exception $e) {
DB::connection('business')->rollBack();
$this->utils->errorThrow($e->getMessage());
}
return [
'created' => $created,
'updated' => $updated,
'price_created' => $priceCreated,
'price_updated' => $priceUpdated,
];
}
/**
* 报价单里真正有值的材质列
*/
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
{
2026-08-20 19:16:49 +08:00
foreach (['cover', 'pdf', 'video'] 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);
}
}
2026-08-20 19:16:49 +08:00
/**
* 导入报价单优先商品编号其次商品ID同商品同规格同尺寸则更新
*/
private function importPriceSheets(array $rows, int $now): array
{
$created = 0;
$updated = 0;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$catalogueId = $this->resolveCatalogueId($row);
$specification = trim((string) ($row['specification'] ?? $row['规格'] ?? ''));
if ($catalogueId <= 0 || $specification === '') {
continue;
}
$dimension = (string) ($row['dimension'] ?? $row['尺寸'] ?? '');
$payload = [
'catalogue_id' => $catalogueId,
'specification' => $specification,
'dimension' => $dimension,
'routine' => (string) ($row['routine'] ?? $row['常规价'] ?? ''),
'stock' => $this->parseStock($row['stock'] ?? $row['库存'] ?? -1),
'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0),
'updated_at' => $now,
];
$id = (int) ($row['id'] ?? $row['ID'] ?? 0);
$exists = $id > 0
? PriceSheetModel::where('id', $id)->where('catalogue_id', $catalogueId)->where('deleted_at', 0)->first()
: null;
if (empty($exists)) {
$exists = PriceSheetModel::where('catalogue_id', $catalogueId)
->where('specification', $specification)
->where('dimension', $dimension)
->where('deleted_at', 0)
->first();
}
if (!empty($exists)) {
PriceSheetModel::where('id', $exists['id'])->update($payload);
$updated++;
} else {
$payload['created_at'] = $now;
PriceSheetModel::insert($payload);
$created++;
}
}
return [$created, $updated];
}
/**
* 报价单行挂到哪件商品编号优先兼容导出里的商品ID
*/
private function resolveCatalogueId(array $row): int
{
$identifier = trim((string) ($row['identifier'] ?? $row['商品编号'] ?? ''));
if ($identifier !== '') {
$cat = CatalogueModel::where('identifier', $identifier)->where('deleted_at', 0)->first();
if (!empty($cat)) {
return (int) $cat['id'];
}
}
$id = (int) ($row['catalogue_id'] ?? $row['商品ID'] ?? 0);
if ($id <= 0) {
return 0;
}
$exists = CatalogueModel::where('id', $id)->where('deleted_at', 0)->exists();
return $exists ? $id : 0;
}
/**
* Excel 状态列可能是「上架/下架」或 0/1,统一成库里的数字
*/
private function parseStatus(mixed $value): int
{
$raw = trim((string) $value);
if (in_array($raw, ['上架', '启用', '正常', '0', ''], true)) {
return 0;
}
if (in_array($raw, ['下架', '禁用', '1'], true)) {
return 1;
}
return ((int) $raw) === 1 ? 1 : 0;
}
/**
* 库存:空 / 不限 -1
*/
private function parseStock(mixed $value): int
{
$raw = trim((string) $value);
if ($raw === '' || in_array($raw, ['不限', '无限', '-1'], true)) {
return -1;
}
return (int) $raw;
}
}