309 lines
12 KiB
PHP
309 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Product;
|
||
|
||
use App\Exceptions\RepositoryException;
|
||
use App\Exports\ProductExport;
|
||
use App\Http\Requests\Product\BatchPriceRequest;
|
||
use App\Http\Requests\Product\ProductFormRequest;
|
||
use App\Imports\ProductImport;
|
||
use App\Models\CustomerLevelModel;
|
||
use App\Models\NoticeModel;
|
||
use App\Models\ProductModel;
|
||
use App\Models\StoreModel;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Maatwebsite\Excel\Facades\Excel;
|
||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||
use Modules\Common\Http\Controllers\BaseController;
|
||
use Modules\SystemTool\Services\SysFileService;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
/**
|
||
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
|
||
*/
|
||
#[RequestAttribute('/product/goods', 'product.goods')]
|
||
class ProductController extends BaseController
|
||
{
|
||
protected array $searchField = [
|
||
'name' => 'like',
|
||
'category_id' => '=',
|
||
'supplier_id' => '=',
|
||
'market' => 'like',
|
||
'status' => '=',
|
||
];
|
||
|
||
protected array $quickSearchField = ['name', 'spec'];
|
||
|
||
/** A1 商品列表(含分类/供应商;prices 为按启用等级上浮比例换算的展示价;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||
#[GetRoute(authorize: 'query')]
|
||
public function query(Request $request): JsonResponse
|
||
{
|
||
$params = $request->all();
|
||
$pageSize = $params['pageSize'] ?? 10;
|
||
$data = $this->buildSearch(
|
||
$params,
|
||
ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
|
||
)
|
||
->orderBy('sort', 'desc')
|
||
->orderBy('id', 'desc')
|
||
->paginate($pageSize);
|
||
$data->getCollection()->makeVisible('cost_price');
|
||
|
||
// 按启用等级直接换算展示价(无等级价格表,价格由成本价 × 等级上浮比例得出)
|
||
$levels = $this->enabledLevels();
|
||
$data->getCollection()->transform(static function (ProductModel $product) use ($levels) {
|
||
$row = $product->toArray();
|
||
$row['prices'] = $levels->map(static fn (CustomerLevelModel $level) => [
|
||
'level_id' => $level->id,
|
||
'level' => ['id' => $level->id, 'name' => $level->name],
|
||
'percent' => (float) $level->percent,
|
||
'price' => CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent),
|
||
])->values()->all();
|
||
return $row;
|
||
});
|
||
|
||
return $this->success($data->toArray());
|
||
}
|
||
|
||
/** 上传商品分类图片文件 */
|
||
#[PostRoute('/upload', 'create')]
|
||
public function uploadImage(Request $request, SysFileService $service): JsonResponse
|
||
{
|
||
$data = $request->validate(['file' => 'required|file']);
|
||
$result = $service->upload(
|
||
$data['file'],
|
||
10,
|
||
20,
|
||
Auth::id()
|
||
);
|
||
return $this->success($result);
|
||
}
|
||
|
||
|
||
/** 创建商品 */
|
||
#[PostRoute(authorize: 'create')]
|
||
public function create(ProductFormRequest $request): JsonResponse
|
||
{
|
||
$product = ProductModel::create($request->validated());
|
||
return $this->success(['id' => $product->id]);
|
||
}
|
||
|
||
/**
|
||
* 商品导出(列格式与导入模板一致,导出文件修改后可直接重新导入);
|
||
* template=1 时仅输出列头 + 示例行(导入模板)
|
||
*/
|
||
#[GetRoute('/export', 'export')]
|
||
public function export(Request $request): Response
|
||
{
|
||
$template = $request->boolean('template');
|
||
$categoryId = max(0, (int) $request->query('category_id', 0));
|
||
$filename = $template
|
||
? '商品导入模板.xlsx'
|
||
: '商品列表_' . now()->format('Ymd_His') . '.xlsx';
|
||
return Excel::download(new ProductExport($categoryId, $template), $filename);
|
||
}
|
||
|
||
/**
|
||
* Excel 批量导入商品:整表校验,有错全部不导入;
|
||
* 分类按名称/路径匹配末级分类,供应商按名称匹配、不存在自动创建;一律新增商品
|
||
*/
|
||
#[PostRoute('/import', 'import')]
|
||
public function import(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||
], [
|
||
'file.required' => '请选择要导入的 Excel 文件',
|
||
'file.mimes' => '仅支持 xlsx/xls 格式的 Excel 文件',
|
||
'file.max' => '文件大小不能超过 10MB',
|
||
]);
|
||
|
||
$import = new ProductImport();
|
||
Excel::import($import, $data['file']);
|
||
|
||
if ($import->errors !== []) {
|
||
return $this->error(
|
||
['errors' => $import->errors],
|
||
'共发现 ' . count($import->errors) . ' 处数据错误,请修正后重新导入'
|
||
);
|
||
}
|
||
|
||
$msg = '成功导入 ' . $import->created . ' 件商品';
|
||
if ($import->suppliersCreated !== []) {
|
||
$msg .= ',自动创建供应商:' . implode('、', $import->suppliersCreated);
|
||
}
|
||
return $this->success(['created' => $import->created], $msg);
|
||
}
|
||
|
||
/** 编辑商品 */
|
||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||
public function update(int $id, ProductFormRequest $request): JsonResponse
|
||
{
|
||
$product = ProductModel::find($id);
|
||
if (empty($product)) {
|
||
throw new RepositoryException('商品不存在');
|
||
}
|
||
$product->update($request->validated());
|
||
return $this->success();
|
||
}
|
||
|
||
/** 删除商品(软删除) */
|
||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||
public function delete(int $id): JsonResponse
|
||
{
|
||
$product = ProductModel::find($id);
|
||
if (empty($product)) {
|
||
throw new RepositoryException('商品不存在');
|
||
}
|
||
$product->delete();
|
||
return $this->success();
|
||
}
|
||
|
||
/** 批量删除商品(软删除),复用 delete 权限点 */
|
||
#[DeleteRoute('/batch', 'delete')]
|
||
public function batchDelete(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'ids' => 'required|array|min:1',
|
||
'ids.*' => 'integer|distinct',
|
||
], [
|
||
'ids.required' => '请选择要删除的商品',
|
||
'ids.min' => '请选择要删除的商品',
|
||
'ids.*.integer' => '商品 ID 格式错误',
|
||
'ids.*.distinct' => '存在重复的商品',
|
||
]);
|
||
ProductModel::whereIn('id', $data['ids'])->delete();
|
||
return $this->success();
|
||
}
|
||
|
||
/**
|
||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
||
* 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑
|
||
*/
|
||
#[GetRoute('/priceMatrix', 'query')]
|
||
public function priceMatrix(Request $request): JsonResponse
|
||
{
|
||
$query = ProductModel::query();
|
||
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
|
||
$query->where('category_id', $categoryId);
|
||
}
|
||
$keyword = trim((string) $request->input('keyword', ''));
|
||
if ($keyword !== '') {
|
||
$query->where(function ($q) use ($keyword) {
|
||
$q->where('name', 'like', '%' . $keyword . '%')
|
||
->orWhere('spec', 'like', '%' . $keyword . '%');
|
||
});
|
||
}
|
||
$pageSize = max(1, min(100, (int) $request->input('pageSize', 20)));
|
||
$page = max(1, (int) $request->input('page', 1));
|
||
$products = $query->orderBy('sort')->orderBy('id')->paginate($pageSize, ['*'], 'page', $page);
|
||
|
||
$levels = $this->enabledLevels();
|
||
|
||
$rows = $products->getCollection()->map(static function (ProductModel $product) use ($levels) {
|
||
$row = [
|
||
'id' => $product->id,
|
||
'name' => $product->name,
|
||
'spec' => $product->spec,
|
||
'unit' => $product->unit,
|
||
'cost_price' => (float) $product->cost_price,
|
||
];
|
||
foreach ($levels as $level) {
|
||
$row['price_' . $level->id] = $product->cost_price > 0
|
||
? (float) CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||
: null;
|
||
}
|
||
return $row;
|
||
});
|
||
|
||
return $this->success([
|
||
'levels' => $levels->map(static fn (CustomerLevelModel $level) => [
|
||
'id' => $level->id,
|
||
'name' => $level->name,
|
||
'percent' => (float) $level->percent,
|
||
])->values()->all(),
|
||
'rows' => $rows->values()->toArray(),
|
||
'total' => $products->total(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* A2 批量调价:批量调整成本价(等级售价随之按上浮比例联动),事务写入,
|
||
* 写完后给全部正常门店的用户生成 Notice(type=price)
|
||
*/
|
||
#[PutRoute('/batchPrice', 'batchPrice')]
|
||
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
||
{
|
||
$updates = $request->validated('updates');
|
||
|
||
DB::transaction(function () use ($updates) {
|
||
$productIds = [];
|
||
foreach ($updates as $row) {
|
||
$productId = (int) $row['product_id'];
|
||
$productIds[$productId] = true;
|
||
ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]);
|
||
}
|
||
|
||
$productNames = ProductModel::whereIn('id', array_keys($productIds))
|
||
->pluck('name')
|
||
->implode('、');
|
||
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
|
||
|
||
// 成本价变更影响所有等级的售价:通知全部正常门店
|
||
$storeIds = StoreModel::query()
|
||
->where('status', StoreModel::STATUS_NORMAL)
|
||
->pluck('id');
|
||
|
||
foreach ($storeIds as $storeId) {
|
||
NoticeModel::create([
|
||
'store_id' => $storeId,
|
||
'type' => NoticeModel::TYPE_PRICE,
|
||
'title' => '商品价格变更',
|
||
'content' => $content,
|
||
'data' => [
|
||
'product_ids' => array_keys($productIds),
|
||
],
|
||
'is_read' => NoticeModel::UNREAD,
|
||
]);
|
||
}
|
||
});
|
||
|
||
return $this->success();
|
||
}
|
||
|
||
/** 商品下拉选项(仅上架,下单等场景用) */
|
||
#[GetRoute('/options', 'query')]
|
||
public function options(Request $request): JsonResponse
|
||
{
|
||
$query = ProductModel::query()->where('status', ProductModel::STATUS_ON);
|
||
$keyword = trim((string) $request->input('keyword', ''));
|
||
if ($keyword !== '') {
|
||
$query->where('name', 'like', '%' . $keyword . '%');
|
||
}
|
||
$data = $query->orderBy('sort')
|
||
->get(['id', 'name', 'spec', 'unit'])
|
||
->toArray();
|
||
return $this->success($data);
|
||
}
|
||
|
||
/**
|
||
* 启用中的客户等级(列表/矩阵共用的等级列来源)
|
||
*
|
||
* @return \Illuminate\Database\Eloquent\Collection<int, CustomerLevelModel>
|
||
*/
|
||
private function enabledLevels(): \Illuminate\Database\Eloquent\Collection
|
||
{
|
||
return CustomerLevelModel::query()
|
||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||
->orderBy('sort')
|
||
->get(['id', 'name', 'percent']);
|
||
}
|
||
}
|