客户等级设置
This commit is contained in:
@@ -5,9 +5,13 @@ namespace App\Http\Controllers\Customer;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Customer\CustomerLevelFormRequest;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
@@ -17,7 +21,7 @@ use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 客户等级管理(同一商品按客户等级定价)
|
||||
* 客户等级管理(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100)
|
||||
*/
|
||||
#[RequestAttribute('/customer/level', 'customer.level')]
|
||||
class CustomerLevelController extends BaseController
|
||||
@@ -63,7 +67,7 @@ class CustomerLevelController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑等级 */
|
||||
/** 编辑等级(上浮比例变更时通知该等级下门店用户) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, CustomerLevelFormRequest $request): JsonResponse
|
||||
{
|
||||
@@ -71,7 +75,14 @@ class CustomerLevelController extends BaseController
|
||||
if (empty($model)) {
|
||||
throw new RepositoryException('客户等级不存在');
|
||||
}
|
||||
$model->update($request->validated());
|
||||
$validated = $request->validated();
|
||||
DB::transaction(function () use ($model, $validated) {
|
||||
$model->update($validated);
|
||||
// 上浮比例变化 → 该等级下所有商品的售价联动变化,通知受影响门店用户
|
||||
if ($model->wasChanged('percent')) {
|
||||
$this->notifyPriceChange($model);
|
||||
}
|
||||
});
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
@@ -86,9 +97,6 @@ class CustomerLevelController extends BaseController
|
||||
if ($model->stores()->exists()) {
|
||||
throw new RepositoryException('该等级下存在门店,无法删除');
|
||||
}
|
||||
if ($model->prices()->exists()) {
|
||||
throw new RepositoryException('该等级下存在商品价格,无法删除');
|
||||
}
|
||||
$model->delete();
|
||||
return $this->success();
|
||||
}
|
||||
@@ -100,8 +108,41 @@ class CustomerLevelController extends BaseController
|
||||
$data = CustomerLevelModel::query()
|
||||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||||
->orderBy('sort')
|
||||
->get(['id', 'name'])
|
||||
->get(['id', 'name', 'percent'])
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上浮比例变更后,给该等级下正常门店绑定的正常用户生成价格变更通知
|
||||
*/
|
||||
private function notifyPriceChange(CustomerLevelModel $level): void
|
||||
{
|
||||
$userIds = UserModel::query()
|
||||
->where('status', UserModel::STATUS_NORMAL)
|
||||
->whereIn('store_id', function ($q) use ($level) {
|
||||
$q->select('id')
|
||||
->from('store')
|
||||
->where('status', StoreModel::STATUS_NORMAL)
|
||||
->where('level_id', $level->id);
|
||||
})
|
||||
->pluck('id');
|
||||
|
||||
$content = mb_substr(
|
||||
'您所在客户等级「' . $level->name . '」的价格上浮比例已调整为 ' . (float) $level->percent . '%,商品价格将按新比例显示',
|
||||
0,
|
||||
500
|
||||
);
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
NoticeModel::create([
|
||||
'user_id' => $userId,
|
||||
'type' => NoticeModel::TYPE_PRICE,
|
||||
'title' => '商品价格变更',
|
||||
'content' => $content,
|
||||
'data' => ['level_id' => $level->id],
|
||||
'is_read' => NoticeModel::UNREAD,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace App\Http\Controllers\Mini;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Mini\MiniCartRequest;
|
||||
use App\Models\CartModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -36,7 +36,7 @@ class CartController extends BaseMiniController
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
if ($store->level_id <= 0) {
|
||||
if ($store->level_id <= 0 || $store->level === null) {
|
||||
return $this->error('门店未设置客户等级,无法加购,请联系客服');
|
||||
}
|
||||
|
||||
@@ -45,14 +45,6 @@ class CartController extends BaseMiniController
|
||||
if ($product === null) {
|
||||
return $this->error('商品不存在或已下架,请刷新后重试');
|
||||
}
|
||||
// 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购)
|
||||
$hasPrice = ProductPriceModel::query()
|
||||
->where('product_id', $productId)
|
||||
->where('level_id', $store->level_id)
|
||||
->exists();
|
||||
if (! $hasPrice) {
|
||||
return $this->error('商品「' . $product->name . '」价格未设置,无法加购');
|
||||
}
|
||||
|
||||
$quantity = (string) $request->validated('quantity');
|
||||
|
||||
@@ -115,11 +107,8 @@ class CartController extends BaseMiniController
|
||||
->unique()->values()->all();
|
||||
|
||||
$products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
|
||||
$priceRows = ProductPriceModel::query()
|
||||
->where('level_id', $store->level_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||
->keyBy('product_id');
|
||||
// 门店等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)
|
||||
$level = $store->level_id > 0 ? $store->level : null;
|
||||
|
||||
// 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。
|
||||
// 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值
|
||||
@@ -141,16 +130,10 @@ class CartController extends BaseMiniController
|
||||
foreach ($rows as $row) {
|
||||
$product = $products->get($row->product_id);
|
||||
$productOn = $product !== null && $product->status === ProductModel::STATUS_ON;
|
||||
// 实际价(百分比计价行按成本价上浮换算);未设等级价为 null
|
||||
$priceRow = $priceRows->get($row->product_id);
|
||||
$price = $priceRow === null
|
||||
// 实际价(按门店等级上浮比例换算);未设等级为 null
|
||||
$price = $level === null
|
||||
? null
|
||||
: ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
(float) ($product?->cost_price ?? 0),
|
||||
);
|
||||
: CustomerLevelModel::calcLevelPrice((float) ($product?->cost_price ?? 0), $level->percent);
|
||||
$buyable = $productOn && $price !== null;
|
||||
$quantity = (string) $row->quantity;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Mini\MiniOrderRequest;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Services\BillNumberService;
|
||||
@@ -32,14 +32,15 @@ class OrderController extends BaseMiniController
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
if ($store->level_id <= 0) {
|
||||
$level = $store->level_id > 0 ? $store->level : null;
|
||||
if ($level === null) {
|
||||
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
|
||||
}
|
||||
|
||||
$items = $request->validated('items');
|
||||
$remark = (string) ($request->validated('remark') ?? '');
|
||||
|
||||
$order = DB::transaction(function () use ($store, $items, $remark) {
|
||||
$order = DB::transaction(function () use ($store, $level, $items, $remark) {
|
||||
$productIds = array_map(static fn ($row) => (int) $row['product_id'], $items);
|
||||
|
||||
$products = ProductModel::query()
|
||||
@@ -48,12 +49,6 @@ class OrderController extends BaseMiniController
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$priceRows = ProductPriceModel::query()
|
||||
->where('level_id', $store->level_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||
->keyBy('product_id');
|
||||
|
||||
$totalQuantity = '0';
|
||||
$totalAmount = '0';
|
||||
$now = now();
|
||||
@@ -64,18 +59,9 @@ class OrderController extends BaseMiniController
|
||||
if ($product === null) {
|
||||
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
|
||||
}
|
||||
if (! isset($priceRows[$productId])) {
|
||||
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
|
||||
}
|
||||
|
||||
// 实际价(百分比计价行按成本价上浮换算,$products 已含 cost_price)
|
||||
$priceRow = $priceRows[$productId];
|
||||
$price = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
$product->cost_price,
|
||||
);
|
||||
// 实际价(按门店等级上浮比例换算,$products 已含 cost_price)
|
||||
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
||||
$quantity = (string) $row['quantity'];
|
||||
$amount = bcmul($price, $quantity, 2);
|
||||
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
@@ -50,30 +50,17 @@ class ProductController extends BaseMiniController
|
||||
->orderBy('id')
|
||||
->paginate($pageSize);
|
||||
|
||||
// 当前门店的等级价格(一次性取出,避免逐行查询)
|
||||
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)
|
||||
$user = $this->optionalUser($request);
|
||||
$store = $user !== null ? $this->boundStore($user) : null;
|
||||
$priceRows = collect();
|
||||
if ($store !== null && $store->level_id > 0) {
|
||||
$priceRows = ProductPriceModel::query()
|
||||
->where('level_id', $store->level_id)
|
||||
->whereIn('product_id', $paginator->getCollection()->pluck('id'))
|
||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||
->keyBy('product_id');
|
||||
}
|
||||
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||
|
||||
$paginator->getCollection()->transform(
|
||||
static function (ProductModel $product) use ($priceRows): array {
|
||||
static function (ProductModel $product) use ($level): array {
|
||||
$row = $product->toArray();
|
||||
// 实际价(百分比计价行按成本价上浮换算;成本价不随序列化输出)
|
||||
$priceRow = $priceRows->get($product->id);
|
||||
$row['price'] = $priceRow !== null
|
||||
? ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
(string) $priceRow->price,
|
||||
(string) $priceRow->percent,
|
||||
(string) $product->cost_price,
|
||||
)
|
||||
// 实际价(按等级上浮比例换算;成本价不随序列化输出)
|
||||
$row['price'] = $level !== null
|
||||
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||
: null;
|
||||
return $row;
|
||||
}
|
||||
@@ -83,7 +70,7 @@ class ProductController extends BaseMiniController
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品详情(免登录浏览;登录门店按等级显示换算价,未登录/未绑店/未设等级 price=null)
|
||||
* 商品详情(免登录浏览;登录门店按等级上浮比例显示换算价,未登录/未绑店/未设等级 price=null)
|
||||
*/
|
||||
#[GetRoute('/product/{id}', authorize: false, where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id, Request $request): JsonResponse
|
||||
@@ -99,18 +86,9 @@ class ProductController extends BaseMiniController
|
||||
$price = null;
|
||||
$user = $this->optionalUser($request);
|
||||
$store = $user !== null ? $this->boundStore($user) : null;
|
||||
if ($store !== null && $store->level_id > 0) {
|
||||
$priceRow = ProductPriceModel::query()
|
||||
->forProductLevel($product->id, $store->level_id)
|
||||
->first(['price', 'price_type', 'percent']);
|
||||
if ($priceRow !== null) {
|
||||
$price = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
(string) $priceRow->price,
|
||||
(string) $priceRow->percent,
|
||||
(string) $product->cost_price,
|
||||
);
|
||||
}
|
||||
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||
if ($level !== null) {
|
||||
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
||||
}
|
||||
|
||||
$data = $product->toArray();
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Http\Requests\Product\ProductFormRequest;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -24,7 +23,7 @@ use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 商品档案管理
|
||||
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
|
||||
*/
|
||||
#[RequestAttribute('/product/goods', 'product.goods')]
|
||||
class ProductController extends BaseController
|
||||
@@ -38,7 +37,7 @@ class ProductController extends BaseController
|
||||
|
||||
protected array $quickSearchField = ['name', 'spec'];
|
||||
|
||||
/** A1 商品列表(含分类/供应商/各等级价格;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||||
/** A1 商品列表(含分类/供应商;prices 为按启用等级上浮比例换算的展示价;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
@@ -46,12 +45,26 @@ class ProductController extends BaseController
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch(
|
||||
$params,
|
||||
ProductModel::query()->with(['category:id,name', 'supplier:id,name', 'prices.level:id,name'])
|
||||
ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
|
||||
)
|
||||
->orderBy('sort')
|
||||
->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());
|
||||
}
|
||||
|
||||
@@ -70,32 +83,15 @@ class ProductController extends BaseController
|
||||
}
|
||||
|
||||
|
||||
/** 创建商品(事务内建商品 + 同步等级价格) */
|
||||
/** 创建商品 */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(ProductFormRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$prices = $validated['prices'] ?? [];
|
||||
unset($validated['prices']);
|
||||
|
||||
$product = DB::transaction(function () use ($validated, $prices) {
|
||||
$product = ProductModel::create($validated);
|
||||
foreach ($prices as $row) {
|
||||
ProductPriceModel::create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => (int) $row['level_id'],
|
||||
'price' => $row['price'],
|
||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
||||
'percent' => $row['percent'] ?? 0,
|
||||
]);
|
||||
}
|
||||
return $product;
|
||||
});
|
||||
|
||||
$product = ProductModel::create($request->validated());
|
||||
return $this->success(['id' => $product->id]);
|
||||
}
|
||||
|
||||
/** 编辑商品(prices 按 level_id upsert,删除已移除的等级行;未提交 prices 键时保持原价) */
|
||||
/** 编辑商品 */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, ProductFormRequest $request): JsonResponse
|
||||
{
|
||||
@@ -103,34 +99,11 @@ class ProductController extends BaseController
|
||||
if (empty($product)) {
|
||||
throw new RepositoryException('商品不存在');
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$prices = $validated['prices'] ?? [];
|
||||
unset($validated['prices']);
|
||||
|
||||
DB::transaction(function () use ($product, $validated, $prices, $request) {
|
||||
$product->update($validated);
|
||||
if ($request->has('prices')) {
|
||||
$levelIds = [];
|
||||
foreach ($prices as $row) {
|
||||
$levelId = (int) $row['level_id'];
|
||||
$levelIds[] = $levelId;
|
||||
ProductPriceModel::updateOrCreate(
|
||||
['product_id' => $product->id, 'level_id' => $levelId],
|
||||
[
|
||||
'price' => $row['price'],
|
||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
||||
'percent' => $row['percent'] ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
$product->prices()->whereNotIn('level_id', $levelIds)->delete();
|
||||
}
|
||||
});
|
||||
|
||||
$product->update($request->validated());
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 删除商品(软删除,连带价格行一并删除) */
|
||||
/** 删除商品(软删除) */
|
||||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
@@ -138,22 +111,18 @@ class ProductController extends BaseController
|
||||
if (empty($product)) {
|
||||
throw new RepositoryException('商品不存在');
|
||||
}
|
||||
DB::transaction(function () use ($product) {
|
||||
$product->prices()->delete();
|
||||
$product->delete();
|
||||
});
|
||||
$product->delete();
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
||||
* 列=全部启用等级,值=实际销售价(缺失为 null);行内含 cost_price 与每等级
|
||||
* price_type_{levelId} / percent_{levelId},供前端判断计价类型与联动重算
|
||||
* 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑
|
||||
*/
|
||||
#[GetRoute('/priceMatrix', 'query')]
|
||||
public function priceMatrix(Request $request): JsonResponse
|
||||
{
|
||||
$query = ProductModel::query()->with('prices:id,product_id,level_id,price,price_type,percent');
|
||||
$query = ProductModel::query();
|
||||
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
|
||||
$query->where('category_id', $categoryId);
|
||||
}
|
||||
@@ -168,13 +137,9 @@ class ProductController extends BaseController
|
||||
$page = max(1, (int) $request->input('page', 1));
|
||||
$products = $query->orderBy('sort')->orderBy('id')->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$levels = CustomerLevelModel::query()
|
||||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||||
->orderBy('sort')
|
||||
->get(['id', 'name']);
|
||||
$levels = $this->enabledLevels();
|
||||
|
||||
$rows = $products->getCollection()->map(function (ProductModel $product) use ($levels) {
|
||||
$priceMap = $product->prices->keyBy('level_id');
|
||||
$rows = $products->getCollection()->map(static function (ProductModel $product) use ($levels) {
|
||||
$row = [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
@@ -183,31 +148,27 @@ class ProductController extends BaseController
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
];
|
||||
foreach ($levels as $level) {
|
||||
$price = $priceMap[$level->id] ?? null;
|
||||
$row['price_' . $level->id] = $price
|
||||
? (float) ProductPriceModel::calcActualPrice(
|
||||
(int) $price->price_type,
|
||||
$price->price,
|
||||
$price->percent,
|
||||
$product->cost_price,
|
||||
)
|
||||
$row['price_' . $level->id] = $product->cost_price > 0
|
||||
? (float) CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||
: null;
|
||||
$row['price_type_' . $level->id] = $price ? (int) $price->price_type : ProductPriceModel::PRICE_TYPE_FIXED;
|
||||
$row['percent_' . $level->id] = $price ? (float) $price->percent : 0;
|
||||
}
|
||||
return $row;
|
||||
});
|
||||
|
||||
return $this->success([
|
||||
'levels' => $levels->toArray(),
|
||||
'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)
|
||||
* A2 批量调价:批量调整成本价(等级售价随之按上浮比例联动),事务写入,
|
||||
* 写完后给全部正常门店的用户生成 Notice(type=price)
|
||||
*/
|
||||
#[PutRoute('/batchPrice', 'batchPrice')]
|
||||
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
||||
@@ -216,53 +177,24 @@ class ProductController extends BaseController
|
||||
|
||||
DB::transaction(function () use ($updates) {
|
||||
$productIds = [];
|
||||
$levelIds = [];
|
||||
foreach ($updates as $row) {
|
||||
$productId = (int) $row['product_id'];
|
||||
$productIds[$productId] = true;
|
||||
|
||||
// 分支1:成本价更新(百分比计价的基数,可与等级价行同在一行)
|
||||
if (array_key_exists('cost_price', $row) && $row['cost_price'] !== null) {
|
||||
ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]);
|
||||
}
|
||||
|
||||
// 分支2/3:等级价格行(固定价或成本百分比,按 price_type 区分)
|
||||
if (isset($row['level_id'])) {
|
||||
$levelId = (int) $row['level_id'];
|
||||
$levelIds[$levelId] = true;
|
||||
ProductPriceModel::updateOrCreate(
|
||||
['product_id' => $productId, 'level_id' => $levelId],
|
||||
[
|
||||
'price' => $row['price'] ?? 0,
|
||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
||||
'percent' => $row['percent'] ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]);
|
||||
}
|
||||
|
||||
// 成本价变更只影响「该商品下百分比计价」的等级;受影响门店等级 = 等级更新行 ∪ 百分比行等级
|
||||
$percentLevelIds = ProductPriceModel::query()
|
||||
->whereIn('product_id', array_keys($productIds))
|
||||
->where('price_type', ProductPriceModel::PRICE_TYPE_PERCENT)
|
||||
->pluck('level_id')
|
||||
->merge($levelIds)
|
||||
->unique()
|
||||
->all();
|
||||
|
||||
$productNames = ProductModel::whereIn('id', array_keys($productIds))
|
||||
->pluck('name')
|
||||
->implode('、');
|
||||
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
|
||||
|
||||
// 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户
|
||||
// 成本价变更影响所有等级的售价:通知全部正常门店绑定的正常用户
|
||||
$userIds = UserModel::query()
|
||||
->where('status', UserModel::STATUS_NORMAL)
|
||||
->whereIn('store_id', function ($q) use ($percentLevelIds) {
|
||||
->whereIn('store_id', function ($q) {
|
||||
$q->select('id')
|
||||
->from('store')
|
||||
->where('status', StoreModel::STATUS_NORMAL)
|
||||
->whereIn('level_id', $percentLevelIds);
|
||||
->where('status', StoreModel::STATUS_NORMAL);
|
||||
})
|
||||
->pluck('id');
|
||||
|
||||
@@ -274,7 +206,6 @@ class ProductController extends BaseController
|
||||
'content' => $content,
|
||||
'data' => [
|
||||
'product_ids' => array_keys($productIds),
|
||||
'level_ids' => array_keys($levelIds),
|
||||
],
|
||||
'is_read' => NoticeModel::UNREAD,
|
||||
]);
|
||||
@@ -298,4 +229,17 @@ class ProductController extends BaseController
|
||||
->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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class CustomerLevelFormRequest extends BaseFormRequest
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:50', $unique],
|
||||
'percent' => 'nullable|numeric|min:0|max:999.99',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||
@@ -35,6 +36,9 @@ class CustomerLevelFormRequest extends BaseFormRequest
|
||||
'name.required' => '等级名称不能为空',
|
||||
'name.max' => '等级名称最长 50 个字符',
|
||||
'name.unique' => '等级名称已存在',
|
||||
'percent.numeric' => '价格上浮比例必须为数字',
|
||||
'percent.min' => '价格上浮比例不能小于 0',
|
||||
'percent.max' => '价格上浮比例不能超过 999.99',
|
||||
'status.in' => '状态值不正确',
|
||||
'icon_id.exists' => '请重新上传图片'
|
||||
];
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use App\Models\ProductPriceModel;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 批量调价 验证(A2 价格矩阵编辑提交)
|
||||
*
|
||||
* updates 每行支持三类更新(可混合同一行):
|
||||
* 等级售价 = 成本价 × (100 + 等级上浮比例) / 100,矩阵仅支持批量调整成本价:
|
||||
* 成本行 {product_id, cost_price}
|
||||
* 固定价行 {product_id, level_id, price_type?:0, price}
|
||||
* 百分比行 {product_id, level_id, price_type:1, percent}(price 可选,等价固定价)
|
||||
*/
|
||||
class BatchPriceRequest extends BaseFormRequest
|
||||
{
|
||||
@@ -24,54 +19,10 @@ class BatchPriceRequest extends BaseFormRequest
|
||||
return [
|
||||
'updates' => 'required|array|min:1',
|
||||
'updates.*.product_id' => 'required|integer|exists:product,id',
|
||||
'updates.*.cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||
'updates.*.level_id' => 'nullable|integer|exists:customer_level,id',
|
||||
'updates.*.price_type' => 'nullable|integer|in:0,1',
|
||||
'updates.*.price' => 'nullable|numeric|min:0|max:99999999',
|
||||
'updates.*.percent' => 'nullable|numeric|min:0|max:999.99',
|
||||
'updates.*.cost_price' => 'required|numeric|min:0|max:99999999',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 行级交叉校验(在 after() 内按实际数据逐行判断,避免 required_with* 通配符参数解析不可靠):
|
||||
* - 每行必须至少包含成本价或等级价格更新
|
||||
* - 出现等级字段(price/percent/price_type)时必须带 level_id
|
||||
* - 等级行必须有 price 或 percent
|
||||
* - 成本百分比计价(price_type=1)时上浮百分点必填
|
||||
*/
|
||||
public function after(): Closure
|
||||
{
|
||||
return function (Validator $validator): void {
|
||||
$data = (array) $validator->getData();
|
||||
foreach ((array) ($data['updates'] ?? []) as $index => $row) {
|
||||
$row = (array) $row;
|
||||
$hasCost = array_key_exists('cost_price', $row) && $row['cost_price'] !== null && $row['cost_price'] !== '';
|
||||
$hasLevel = isset($row['level_id']);
|
||||
$hasPrice = array_key_exists('price', $row) && $row['price'] !== null && $row['price'] !== '';
|
||||
$hasPercent = array_key_exists('percent', $row) && $row['percent'] !== null && $row['percent'] !== '';
|
||||
$hasType = array_key_exists('price_type', $row);
|
||||
|
||||
if (! $hasCost && ! $hasLevel) {
|
||||
$validator->errors()->add("updates.{$index}", '调价行缺少成本价或等级价格');
|
||||
continue;
|
||||
}
|
||||
if (($hasPrice || $hasPercent || $hasType) && ! $hasLevel) {
|
||||
$validator->errors()->add("updates.{$index}.level_id", '等级价格行缺少客户等级');
|
||||
continue;
|
||||
}
|
||||
if ($hasLevel && ! $hasPrice && ! $hasPercent) {
|
||||
$validator->errors()->add("updates.{$index}", '等级价格行缺少单价或上浮百分点');
|
||||
continue;
|
||||
}
|
||||
|
||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT && ! $hasPercent) {
|
||||
$validator->errors()->add("updates.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
@@ -79,15 +30,9 @@ class BatchPriceRequest extends BaseFormRequest
|
||||
'updates.min' => '请至少提交一条价格调整',
|
||||
'updates.*.product_id.required' => '调价行缺少商品',
|
||||
'updates.*.product_id.exists' => '商品不存在',
|
||||
'updates.*.cost_price.required' => '调价行缺少成本价',
|
||||
'updates.*.cost_price.numeric' => '成本价必须为数字',
|
||||
'updates.*.cost_price.min' => '成本价不能小于 0',
|
||||
'updates.*.level_id.exists' => '客户等级不存在',
|
||||
'updates.*.price_type.in' => '计价类型不正确',
|
||||
'updates.*.price.numeric' => '单价必须为数字',
|
||||
'updates.*.price.min' => '单价不能小于 0',
|
||||
'updates.*.percent.numeric' => '上浮百分点必须为数字',
|
||||
'updates.*.percent.min' => '上浮百分点不能小于 0',
|
||||
'updates.*.percent.max' => '上浮百分点不能超过 999.99',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
@@ -12,7 +11,7 @@ use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品档案 创建/编辑 验证(含多等级价格 prices 数组)
|
||||
* 商品档案 创建/编辑 验证(售价按客户等级上浮比例换算,不再维护等级价格行)
|
||||
*/
|
||||
class ProductFormRequest extends BaseFormRequest
|
||||
{
|
||||
@@ -35,16 +34,11 @@ class ProductFormRequest extends BaseFormRequest
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
'prices' => 'nullable|array',
|
||||
'prices.*.level_id' => 'required|integer|exists:customer_level,id',
|
||||
'prices.*.price_type' => 'nullable|integer|in:0,1',
|
||||
'prices.*.price' => 'required|numeric|min:0|max:99999999',
|
||||
'prices.*.percent' => 'nullable|numeric|min:0|max:999.99',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 交叉校验:商品只能挂在末级分类;按成本百分比计价(price_type=1)时上浮百分点必填
|
||||
* 交叉校验:商品只能挂在末级分类
|
||||
* (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator)
|
||||
*/
|
||||
public function after(): Closure
|
||||
@@ -55,13 +49,6 @@ class ProductFormRequest extends BaseFormRequest
|
||||
if ($categoryId > 0 && ProductCategoryModel::where('parent_id', $categoryId)->exists()) {
|
||||
$validator->errors()->add('category_id', '该分类下存在子分类,请选择末级分类');
|
||||
}
|
||||
foreach ((array) ($data['prices'] ?? []) as $index => $row) {
|
||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT
|
||||
&& (! array_key_exists('percent', (array) $row) || $row['percent'] === null || $row['percent'] === '')) {
|
||||
$validator->errors()->add("prices.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,17 +61,8 @@ class ProductFormRequest extends BaseFormRequest
|
||||
'category_id.exists' => '商品分类不存在',
|
||||
'supplier_id.exists' => '供应商不存在',
|
||||
'status.in' => '状态值不正确',
|
||||
'prices.*.level_id.required' => '价格行缺少客户等级',
|
||||
'prices.*.level_id.exists' => '客户等级不存在',
|
||||
'prices.*.price.required' => '价格行缺少单价',
|
||||
'prices.*.price.numeric' => '单价必须为数字',
|
||||
'prices.*.price.min' => '单价不能小于 0',
|
||||
'cost_price.numeric' => '成本价必须为数字',
|
||||
'cost_price.min' => '成本价不能小于 0',
|
||||
'prices.*.price_type.in' => '计价类型不正确',
|
||||
'prices.*.percent.numeric' => '上浮百分点必须为数字',
|
||||
'prices.*.percent.min' => '上浮百分点不能小于 0',
|
||||
'prices.*.percent.max' => '上浮百分点不能超过 999.99',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 客户等级模型(同一商品按客户等级定价)
|
||||
* 客户等级模型(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100)
|
||||
*/
|
||||
class CustomerLevelModel extends Model
|
||||
{
|
||||
@@ -25,12 +25,14 @@ class CustomerLevelModel extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'percent',
|
||||
'sort',
|
||||
'status',
|
||||
'icon_id'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'percent' => 'decimal:2',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
@@ -65,10 +67,17 @@ class CustomerLevelModel extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 该等级下的商品价格
|
||||
* 按等级上浮比例计算售价(统一换算入口,金额走 bcmath 保证两位小数精度)
|
||||
*
|
||||
* 售价 = 成本价 × (100 + percent) / 100(四舍五入保留两位)。
|
||||
*
|
||||
* @param string|int|float $costPrice 商品成本价(decimal cast 字符串)
|
||||
* @param string|int|float $percent 价格上浮比例(30 = 上浮 30%)
|
||||
* @return string 两位小数字符串,如 '13.05'
|
||||
*/
|
||||
public function prices(): HasMany
|
||||
public static function calcLevelPrice(string|int|float $costPrice, string|int|float $percent): string
|
||||
{
|
||||
return $this->hasMany(ProductPriceModel::class, 'level_id', 'id');
|
||||
$multiplier = bcadd('100', (string) $percent, 4);
|
||||
return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品档案模型(品名/规格包规/供应商/等级/多等级价格体系)
|
||||
* 商品档案模型(品名/规格包规/供应商/成本价;售价按客户等级上浮比例换算)
|
||||
*/
|
||||
class ProductModel extends Model
|
||||
{
|
||||
@@ -96,12 +95,4 @@ class ProductModel extends Model
|
||||
{
|
||||
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 多等级价格(同一商品按客户等级定价)
|
||||
*/
|
||||
public function prices(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductPriceModel::class, 'product_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id)
|
||||
*
|
||||
* 计价类型:固定价(price 即实际单价)或成本百分比(实际单价 = 成本价 × (100 + percent) / 100)
|
||||
*/
|
||||
class ProductPriceModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 计价类型:固定价 */
|
||||
public const int PRICE_TYPE_FIXED = 0;
|
||||
/** 计价类型:成本百分比(按成本价上浮 percent 百分点) */
|
||||
public const int PRICE_TYPE_PERCENT = 1;
|
||||
|
||||
protected $table = 'product_price';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'level_id',
|
||||
'price',
|
||||
'price_type',
|
||||
'percent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_id' => 'integer',
|
||||
'level_id' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'price_type' => 'integer',
|
||||
'percent' => 'decimal:2',
|
||||
];
|
||||
|
||||
/** 序列化时附带实际销售价(后台列表/小程序列表直接展示) */
|
||||
protected $appends = ['actual_price'];
|
||||
|
||||
/**
|
||||
* 所属商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属客户等级
|
||||
*/
|
||||
public function level(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerLevelModel::class, 'level_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商品 + 等级筛选价格
|
||||
*/
|
||||
public function scopeForProductLevel($query, int $productId, int $levelId)
|
||||
{
|
||||
return $query->where('product_id', $productId)->where('level_id', $levelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算实际销售价(统一换算入口,金额走 bcmath 保证两位小数精度)
|
||||
*
|
||||
* 固定价返回 price 原值;成本百分比返回 cost × (100 + percent) / 100(四舍五入保留两位)。
|
||||
*
|
||||
* @param string|int|float $price 固定价(decimal cast 后为 '5.50' 形式字符串)
|
||||
* @param string|int|float $percent 成本上浮百分点(30 = 上浮 30%)
|
||||
* @param string|int|float $costPrice 商品成本价(decimal cast 字符串)
|
||||
* @return string 两位小数字符串,如 '13.05'
|
||||
*/
|
||||
public static function calcActualPrice(
|
||||
int $priceType,
|
||||
string|int|float $price,
|
||||
string|int|float $percent,
|
||||
string|int|float $costPrice,
|
||||
): string {
|
||||
if ($priceType === self::PRICE_TYPE_PERCENT) {
|
||||
$multiplier = bcadd('100', (string) $percent, 4);
|
||||
return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2);
|
||||
}
|
||||
// 固定价:归一化为两位小数字符串
|
||||
return bcadd((string) $price, '0', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际销售价访问器(供 toArray 输出 actual_price)
|
||||
*
|
||||
* 依赖 product 关系取成本价;prices 经商品 eager load 加载时逆向关系自动填充,无 N+1。
|
||||
* 注意:单独序列化本模型且未加载 product 关系时会触发一次查询,成本价缺失按 0 兜底。
|
||||
*/
|
||||
protected function getActualPriceAttribute(): string
|
||||
{
|
||||
return self::calcActualPrice(
|
||||
(int) $this->price_type,
|
||||
(string) $this->price,
|
||||
(string) $this->percent,
|
||||
(string) ($this->product?->cost_price ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user