客户等级设置
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProductPriceModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 商品等级价格工厂(product_id / level_id 需调用方指定;无需 Faker)
|
||||
*
|
||||
* @extends Factory<ProductPriceModel>
|
||||
*/
|
||||
class ProductPriceModelFactory extends Factory
|
||||
{
|
||||
protected $model = ProductPriceModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'product_id' => 0,
|
||||
'level_id' => 0,
|
||||
'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''),
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_FIXED,
|
||||
'percent' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 价格体系重构:商品等级价格表 → 客户等级固定上浮比例
|
||||
* (售价 = 成本价 × (100 + percent) / 100,product_price 表废弃删除)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 客户等级表加价格上浮比例(全守卫幂等:开发库若已手工加列则跳过)
|
||||
if (! Schema::hasColumn('customer_level', 'percent')) {
|
||||
Schema::table('customer_level', function (Blueprint $table) {
|
||||
$table->decimal('percent', 5, 2)->default(0)->after('name')->comment('价格上浮比例(%,如 30 = 成本价上浮30%)');
|
||||
});
|
||||
}
|
||||
|
||||
// 商品等级价格表废弃(历史数据随表删除)
|
||||
Schema::dropIfExists('product_price');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 恢复商品等级价格表(结构回滚,历史数据不恢复)
|
||||
if (! Schema::hasTable('product_price')) {
|
||||
Schema::create('product_price', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('价格ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->integer('level_id')->comment('客户等级ID');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价(固定价=实际单价;百分比=等价固定价)');
|
||||
$table->unsignedTinyInteger('price_type')->default(0)->comment('计价类型(0固定价 1成本百分比)');
|
||||
$table->decimal('percent', 5, 2)->default(0)->comment('成本上浮百分点(如 30 = 上浮30%,仅 price_type=1 生效)');
|
||||
$table->timestamps();
|
||||
$table->unique(['product_id', 'level_id'], 'product_price_product_level_unique');
|
||||
$table->comment('商品等级价格表');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('customer_level', 'percent')) {
|
||||
Schema::table('customer_level', function (Blueprint $table) {
|
||||
$table->dropColumn('percent');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,6 @@ use App\Models\BillModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
@@ -29,12 +28,7 @@ class BillPaymentTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.00',
|
||||
]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
@@ -85,8 +79,7 @@ class BillPaymentTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
+19
-28
@@ -5,17 +5,16 @@ namespace Tests\Feature;
|
||||
use App\Models\CartModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 小程序购物车:加购合并、等级价/金额服务端计算、归属校验、数据隔离、清空
|
||||
* 小程序购物车:加购合并、等级上浮价/金额服务端计算、归属校验、数据隔离、清空
|
||||
*/
|
||||
class CartTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造一家门店 + 一个上架商品(含等级价)+ 该店用户
|
||||
* 造一家门店 + 一个上架商品(成本价即售价,等级上浮 0%)+ 该店用户
|
||||
*
|
||||
* @return array{0: StoreModel, 1: ProductModel, 2: UserModel}
|
||||
*/
|
||||
@@ -23,11 +22,9 @@ class CartTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => $price,
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
@@ -94,15 +91,16 @@ class CartTest extends ProcurementTestCase
|
||||
$this->assertSame(0, CartModel::count());
|
||||
}
|
||||
|
||||
/** 商品未设置门店等级价时拒绝加购 */
|
||||
public function test_product_without_level_price_rejected(): void
|
||||
/** 门店绑定的客户等级被删除时拒绝加购 */
|
||||
public function test_store_level_missing_rejected(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
ProductPriceModel::where('product_id', $product->id)->delete();
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct();
|
||||
CustomerLevelModel::whereKey($store->level_id)->delete();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
|
||||
->assertJsonPath('success', false);
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '门店未设置客户等级,无法加购,请联系客服');
|
||||
|
||||
$this->assertSame(0, CartModel::count());
|
||||
}
|
||||
@@ -129,15 +127,13 @@ class CartTest extends ProcurementTestCase
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 列表:实时等级价、服务端金额、汇总(同店两商品,新加入在前) */
|
||||
/** 列表:实时等级上浮价、服务端金额、汇总(同店两商品,新加入在前) */
|
||||
public function test_list_with_prices_amounts_and_totals(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.50']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '3.00']);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.50']);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '3.00']);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 3]);
|
||||
@@ -165,10 +161,8 @@ class CartTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.50']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '3.00']);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.50']);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '3.00']);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 3]);
|
||||
@@ -283,10 +277,8 @@ class CartTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
$userA = UserModel::factory()->forStore($store->id)->create();
|
||||
$userB = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
@@ -315,8 +307,7 @@ class CartTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
$userA = UserModel::factory()->forStore($store->id)->create();
|
||||
$userB = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Models\BillModel;
|
||||
use App\Models\ContainerReturnModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
@@ -58,12 +57,7 @@ class ContainerReturnTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.00',
|
||||
]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace Tests\Feature;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
@@ -27,10 +26,8 @@ class ExportTest extends ProcurementTestCase
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$veg = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $vegRoot->id]);
|
||||
$meat = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $meatRoot->id]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $veg->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $meat->id, 'level_id' => $level->id, 'price' => '20.00']);
|
||||
$veg = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $vegRoot->id, 'cost_price' => '5.00']);
|
||||
$meat = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $meatRoot->id, 'cost_price' => '20.00']);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [
|
||||
|
||||
@@ -4,18 +4,17 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 小程序商品:商品详情接口(免登录浏览,登录门店按等级显示换算价);
|
||||
* 商品列表回归:登录门店场景曾数组误用对象访问 + 价格行缺失导致 500
|
||||
* 小程序商品:商品详情接口(免登录浏览,登录门店按等级上浮比例显示换算价);
|
||||
* 商品列表回归:登录门店场景曾数组误用对象访问 + 价格缺失导致 500
|
||||
*/
|
||||
class MiniProductTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造门店 + 上架商品 + 等级价(固定价 5.00)
|
||||
* 造门店 + 上架商品(成本价即售价,等级上浮 0%)
|
||||
*
|
||||
* @return array{0: StoreModel, 1: ProductModel, 2: UserModel, 3: CustomerLevelModel}
|
||||
*/
|
||||
@@ -23,11 +22,9 @@ class MiniProductTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => $price,
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create(), $level];
|
||||
@@ -58,12 +55,11 @@ class MiniProductTest extends ProcurementTestCase
|
||||
$this->assertSame('6.50', $data['price']);
|
||||
}
|
||||
|
||||
/** 商品详情:百分比计价按最新成本价上浮换算 */
|
||||
/** 商品详情:按门店等级上浮比例换算(成本价上浮 30%) */
|
||||
public function test_product_detail_percent_price_calculated_from_cost(): void
|
||||
{
|
||||
[, $product, $user, $level] = $this->makeStoreWithProduct();
|
||||
ProductPriceModel::where('product_id', $product->id)->where('level_id', $level->id)
|
||||
->update(['price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => '30']);
|
||||
$level->update(['percent' => 30]);
|
||||
$product->update(['cost_price' => '20.00']);
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
|
||||
@@ -6,13 +6,12 @@ use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 商品成本价 + 等级百分比计价:
|
||||
* 实际价换算、后台/小程序列表展示、创建编辑切换计价类型、批量调价(成本价/百分比)、成本价防泄漏
|
||||
* 商品成本价 + 等级上浮计价:
|
||||
* 售价换算、后台/小程序列表展示、批量调价(成本价)、成本价防泄漏
|
||||
*/
|
||||
class ProductCostPriceTest extends ProcurementTestCase
|
||||
{
|
||||
@@ -27,80 +26,41 @@ class ProductCostPriceTest extends ProcurementTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/** 造门店 + 上架商品(成本价 + 指定计价类型的价格行) + 该店用户 */
|
||||
/** 造门店(等级上浮 percent)+ 上架商品(成本价 cost) + 该店用户 */
|
||||
private function makeStoreWithPercentProduct(float $cost = 10, float $percent = 30): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => $percent]);
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $cost,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => $percent,
|
||||
'price' => 13.00,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
|
||||
/** 实际价换算:固定价原值;百分比 = 成本 × (100 + percent) / 100 */
|
||||
public function test_calc_actual_price_fixed_and_percent(): void
|
||||
/** 售价换算:成本价 × (100 + percent) / 100 */
|
||||
public function test_calc_level_price(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'5.50',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_FIXED, '5.50', '0', '10.00'),
|
||||
'固定价原样返回'
|
||||
);
|
||||
$this->assertSame(
|
||||
'13.00',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '10.00'),
|
||||
'10 元上浮 30% = 13.00'
|
||||
);
|
||||
$this->assertSame(
|
||||
'13.05',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30.50', '10.00'),
|
||||
'10 元上浮 30.5% = 13.05'
|
||||
);
|
||||
$this->assertSame('13.00', CustomerLevelModel::calcLevelPrice('10.00', '30'), '10 元上浮 30% = 13.00');
|
||||
$this->assertSame('13.05', CustomerLevelModel::calcLevelPrice('10.00', '30.50'), '10 元上浮 30.5% = 13.05');
|
||||
$this->assertSame('5.50', CustomerLevelModel::calcLevelPrice('5.50', '0'), '上浮 0% = 成本价');
|
||||
}
|
||||
|
||||
/** 换算边界:成本为 0、上浮 0%、非法类型兜底 */
|
||||
public function test_calc_actual_price_edge_cases(): void
|
||||
/** 换算边界:成本为 0 时售价按 0 兜底 */
|
||||
public function test_calc_level_price_edge_cases(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'0.00',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '0'),
|
||||
'成本为 0 时实际价按 0 兜底'
|
||||
);
|
||||
$this->assertSame(
|
||||
'10.00',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '0', '10.00'),
|
||||
'上浮 0% = 成本价'
|
||||
);
|
||||
$this->assertSame(
|
||||
'5.50',
|
||||
ProductPriceModel::calcActualPrice(99, '5.50', '30', '10.00'),
|
||||
'非法计价类型按固定价兜底'
|
||||
);
|
||||
$this->assertSame('0.00', CustomerLevelModel::calcLevelPrice('0', '30'), '成本为 0 时售价按 0 兜底');
|
||||
$this->assertSame('10.00', CustomerLevelModel::calcLevelPrice('10.00', '0'), '上浮 0% = 成本价');
|
||||
}
|
||||
|
||||
/** 后台商品列表:含成本价列 + 等级价格行的 actual_price */
|
||||
public function test_admin_list_contains_cost_price_and_actual_price(): void
|
||||
/** 后台商品列表:含成本价列 + 各启用等级的换算价 */
|
||||
public function test_admin_list_contains_cost_price_and_level_prices(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 30]);
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10.00]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
'price' => 13.00,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/product/goods');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
@@ -108,7 +68,11 @@ class ProductCostPriceTest extends ProcurementTestCase
|
||||
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame('10.00', (string) $row['cost_price'], '后台列表应含成本价');
|
||||
$this->assertSame('13.00', (string) $row['prices'][0]['actual_price'], '后台列表等级价格应为实际价');
|
||||
|
||||
$priceRow = collect($row['prices'])->firstWhere('level_id', $level->id);
|
||||
$this->assertNotNull($priceRow, '后台列表应含该等级的换算价');
|
||||
$this->assertSame('13.00', (string) $priceRow['price'], '10 元上浮 30% = 13.00');
|
||||
$this->assertSame(30.0, (float) $priceRow['percent']);
|
||||
}
|
||||
|
||||
/** 小程序列表:返回换算后实际价,且成本价不泄漏 */
|
||||
@@ -122,186 +86,59 @@ class ProductCostPriceTest extends ProcurementTestCase
|
||||
|
||||
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame('13.00', (string) $row['price'], '小程序端应返回百分比换算后的实际价');
|
||||
$this->assertSame('13.00', (string) $row['price'], '小程序端应返回按等级上浮换算的实际价');
|
||||
$this->assertArrayNotHasKey('cost_price', $row, '成本价为商业敏感数据,不得泄漏到小程序端');
|
||||
$this->assertArrayNotHasKey('prices', $row, '价格行原始数据(含 percent)也不应下发给小程序');
|
||||
$this->assertArrayNotHasKey('prices', $row, '价格明细(含上浮比例)不应下发给小程序');
|
||||
}
|
||||
|
||||
/** 后台创建商品:成本价 + 百分比计价行落库 */
|
||||
public function test_create_product_with_cost_and_percent_pricing(): void
|
||||
/** 后台创建商品:成本价落库(售价由等级上浮比例换算,不再随商品提交价格行) */
|
||||
public function test_create_product_with_cost_price(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$category = $this->makeCategory();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
|
||||
$this->postJson('/product/goods', [
|
||||
'category_id' => $category->id,
|
||||
'name' => '百分比商品',
|
||||
'name' => '上浮计价商品',
|
||||
'content' => '测试图文详情',
|
||||
'cost_price' => 10,
|
||||
'prices' => [
|
||||
['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 30, 'price' => 13],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$product = ProductModel::where('name', '百分比商品')->first();
|
||||
$product = ProductModel::where('name', '上浮计价商品')->first();
|
||||
$this->assertNotNull($product);
|
||||
$this->assertSame('10.00', (string) $product->cost_price);
|
||||
|
||||
$price = $product->prices->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('30.00', (string) $price->percent);
|
||||
$this->assertSame('13.00', (string) $price->actual_price, '百分比行实际价应按成本价换算');
|
||||
}
|
||||
|
||||
/** 百分比计价缺少上浮百分点 → 验证失败(XinAdmin 验证错误为 200 + success=false) */
|
||||
public function test_create_percent_row_without_percent_fails(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$category = $this->makeCategory();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
|
||||
$this->postJson('/product/goods', [
|
||||
'category_id' => $category->id,
|
||||
'name' => '缺百分比',
|
||||
'content' => '',
|
||||
'prices' => [
|
||||
// 有单价但缺上浮百分点:after() 交叉校验应拦截
|
||||
['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'price' => 13],
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '按成本百分比计价时必须填写上浮百分点');
|
||||
}
|
||||
|
||||
/** 编辑商品:从固定价切换为成本百分比计价 */
|
||||
public function test_update_switches_fixed_to_percent(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$category = $this->makeCategory();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
|
||||
$product = ProductModel::factory()->create(['category_id' => $category->id, 'cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => 5.00,
|
||||
]);
|
||||
|
||||
$this->putJson('/product/goods/' . $product->id, [
|
||||
'category_id' => $category->id,
|
||||
'name' => $product->name,
|
||||
'cost_price' => 10,
|
||||
'prices' => [
|
||||
['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 50, 'price' => 15],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$price = ProductPriceModel::forProductLevel($product->id, $level->id)->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('50.00', (string) $price->percent);
|
||||
$this->assertSame('15.00', (string) $price->actual_price, '10 元上浮 50% = 15.00');
|
||||
}
|
||||
|
||||
/** 批量调价-纯成本价行:百分比等级门店收到通知,纯固定价等级门店不通知 */
|
||||
public function test_batch_price_cost_only_notifies_percent_level_stores(): void
|
||||
/** 批量调价-成本价行:更新成本并通知门店;等级售价随上浮比例联动 */
|
||||
public function test_batch_price_cost_row_update(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$percentLevel = CustomerLevelModel::factory()->create();
|
||||
$fixedLevel = CustomerLevelModel::factory()->create();
|
||||
$percentStore = StoreModel::factory()->create(['level_id' => $percentLevel->id]);
|
||||
$fixedStore = StoreModel::factory()->create(['level_id' => $fixedLevel->id]);
|
||||
$percentUser = UserModel::factory()->forStore($percentStore->id)->create();
|
||||
$fixedUser = UserModel::factory()->forStore($fixedStore->id)->create();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 30]);
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $percentLevel->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
'price' => 13,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $fixedLevel->id,
|
||||
'price' => 8,
|
||||
]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
['product_id' => $product->id, 'cost_price' => 12],
|
||||
['product_id' => $product->id, 'cost_price' => 20],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame('12.00', (string) $product->fresh()->cost_price, '成本价应已更新');
|
||||
$this->assertSame('20.00', (string) $product->fresh()->cost_price, '成本价应已更新');
|
||||
$this->assertSame(
|
||||
1,
|
||||
NoticeModel::where('user_id', $percentUser->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'成本价变更影响百分比计价等级,该等级门店用户应收到通知'
|
||||
);
|
||||
$this->assertSame(
|
||||
0,
|
||||
NoticeModel::where('user_id', $fixedUser->id)->count(),
|
||||
'固定价等级不受成本价变更影响,不应收到通知'
|
||||
NoticeModel::where('user_id', $user->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'门店用户应收到价格变更通知'
|
||||
);
|
||||
|
||||
// 等级售价联动:20 元上浮 30% = 26.00
|
||||
$this->actingAsMiniUser($user);
|
||||
$row = collect($this->getJson('/mini/product/list')->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertSame('26.00', (string) $row['price'], '成本变更后小程序端售价应按上浮比例联动');
|
||||
}
|
||||
|
||||
/** 批量调价-百分比行:存 percent 与等价固定价 */
|
||||
public function test_batch_price_percent_row_update(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 40,
|
||||
'price' => 14,
|
||||
],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$price = ProductPriceModel::forProductLevel($product->id, $level->id)->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('40.00', (string) $price->percent);
|
||||
$this->assertSame('14.00', (string) $price->actual_price, '10 元上浮 40% = 14.00');
|
||||
}
|
||||
|
||||
/** 批量调价-混合行:同一行同时改成本价与等级价 */
|
||||
public function test_batch_price_mixed_row_updates_cost_and_level(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 8]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'cost_price' => 12,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 25,
|
||||
'price' => 15,
|
||||
],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame('12.00', (string) $product->fresh()->cost_price);
|
||||
$price = ProductPriceModel::forProductLevel($product->id, $level->id)->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('25.00', (string) $price->percent);
|
||||
$this->assertSame('15.00', (string) $price->actual_price, '12 元上浮 25% = 15.00');
|
||||
}
|
||||
|
||||
/** 批量调价-空行(仅 product_id)→ 验证失败 */
|
||||
/** 批量调价-空行(仅 product_id,缺成本价)→ 验证失败 */
|
||||
public function test_batch_price_empty_row_rejected(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
@@ -313,23 +150,17 @@ class ProductCostPriceTest extends ProcurementTestCase
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '调价行缺少成本价或等级价格');
|
||||
->assertJsonPath('msg', '调价行缺少成本价');
|
||||
}
|
||||
|
||||
/** 价格矩阵:行含成本价与每等级的计价类型/百分比,等级格为实际价 */
|
||||
public function test_price_matrix_includes_cost_and_percent_columns(): void
|
||||
/** 价格矩阵:行含成本价,等级格为按上浮比例换算的售价;成本价未设置时等级格为 null */
|
||||
public function test_price_matrix_includes_cost_and_level_price_columns(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 30]);
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
'price' => 13,
|
||||
]);
|
||||
$noCostProduct = ProductModel::factory()->create(['cost_price' => 0]);
|
||||
|
||||
$response = $this->getJson('/product/goods/priceMatrix');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
@@ -337,13 +168,15 @@ class ProductCostPriceTest extends ProcurementTestCase
|
||||
$row = collect($response->json('data.rows'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(10.0, (float) $row['cost_price']);
|
||||
$this->assertSame(13.0, (float) $row['price_' . $level->id], '矩阵等级格应显示实际价');
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $row['price_type_' . $level->id]);
|
||||
$this->assertSame(30.0, (float) $row['percent_' . $level->id]);
|
||||
$this->assertSame(13.0, (float) $row['price_' . $level->id], '矩阵等级格应显示换算价');
|
||||
|
||||
$noCostRow = collect($response->json('data.rows'))->firstWhere('id', $noCostProduct->id);
|
||||
$this->assertNotNull($noCostRow);
|
||||
$this->assertNull($noCostRow['price_' . $level->id], '成本价未设置时等级格应为 null');
|
||||
}
|
||||
|
||||
/** 小程序下单:百分比计价行按实际价重算金额 */
|
||||
public function test_mini_order_uses_actual_price_for_percent_row(): void
|
||||
/** 小程序下单:按等级上浮换算价重算金额并快照 */
|
||||
public function test_mini_order_uses_level_price(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30);
|
||||
$this->actingAsMiniUser($user);
|
||||
@@ -355,12 +188,12 @@ class ProductCostPriceTest extends ProcurementTestCase
|
||||
->assertJsonPath('data.total_amount', '39.00');
|
||||
|
||||
$item = $store->orders()->latest('id')->first()->items->first();
|
||||
$this->assertSame('13.00', (string) $item->price, '订单明细快照应为实际价');
|
||||
$this->assertSame('13.00', (string) $item->price, '订单明细快照应为换算价');
|
||||
$this->assertSame('39.00', (string) $item->amount);
|
||||
}
|
||||
|
||||
/** 小程序购物车:列表金额按百分比换算的实际价计算 */
|
||||
public function test_mini_cart_uses_actual_price_for_percent_row(): void
|
||||
/** 小程序购物车:列表金额按等级上浮换算价计算 */
|
||||
public function test_mini_cart_uses_level_price(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30);
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
@@ -5,33 +5,34 @@ namespace Tests\Feature;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* A2 等级价格体系:等级价匹配、批量调价事务与调价通知
|
||||
* A2 等级价格体系(等级上浮比例):列表按等级换算价、批量调价(成本价)事务与调价通知、
|
||||
* 等级上浮比例变更通知
|
||||
*/
|
||||
class ProductPriceTest extends ProcurementTestCase
|
||||
{
|
||||
/** 小程序商品列表返回当前门店等级对应的价格 */
|
||||
/** 小程序商品列表按门店等级的上浮比例返回换算价(不同等级不同价) */
|
||||
public function test_product_list_returns_price_for_store_level(): void
|
||||
{
|
||||
$levelA = CustomerLevelModel::factory()->create();
|
||||
$levelB = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $levelA->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelA->id, 'price' => 5.50]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelB->id, 'price' => 3.00]);
|
||||
$levelA = CustomerLevelModel::factory()->create(['percent' => 30]);
|
||||
$levelB = CustomerLevelModel::factory()->create(['percent' => 10]);
|
||||
$storeA = StoreModel::factory()->create(['level_id' => $levelA->id]);
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $levelB->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => 10.00]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($storeA->id)->create());
|
||||
$rowA = collect($this->getJson('/mini/product/list')->assertOk()->json('data.data'))
|
||||
->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($rowA, '商品应出现在列表中');
|
||||
$this->assertSame(13.00, (float) $rowA['price'], 'A 等级:10 元上浮 30% = 13.00');
|
||||
|
||||
$response = $this->getJson('/mini/product/list');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row, '商品应出现在列表中');
|
||||
$this->assertSame(5.50, (float) $row['price'], '应返回门店所在等级的价格');
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create());
|
||||
$rowB = collect($this->getJson('/mini/product/list')->assertOk()->json('data.data'))
|
||||
->firstWhere('id', $product->id);
|
||||
$this->assertSame(11.00, (float) $rowB['price'], 'B 等级:10 元上浮 10% = 11.00');
|
||||
}
|
||||
|
||||
/** 门店未设置客户等级:仍可浏览商品,但价格不可见(price=null) */
|
||||
@@ -71,59 +72,123 @@ class ProductPriceTest extends ProcurementTestCase
|
||||
->assertJsonPath('msg', '尚未绑定门店,请联系客服处理');
|
||||
}
|
||||
|
||||
/** 批量调价:事务写入 + 通知受影响门店用户(不影响无关门店) */
|
||||
public function test_batch_price_updates_and_notifies_affected_stores(): void
|
||||
/** 批量调价(成本价):事务写入 + 通知全部正常门店用户(成本变更影响所有等级售价) */
|
||||
public function test_batch_price_updates_cost_and_notifies_all_stores(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$affectedStore = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$unaffectedStore = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]);
|
||||
$affectedUser = UserModel::factory()->forStore($affectedStore->id)->create();
|
||||
$unaffectedUser = UserModel::factory()->forStore($unaffectedStore->id)->create();
|
||||
$storeA = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]);
|
||||
$storeB = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]);
|
||||
$userA = UserModel::factory()->forStore($storeA->id)->create();
|
||||
$userB = UserModel::factory()->forStore($storeB->id)->create();
|
||||
|
||||
$product = ProductModel::factory()->create();
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
['product_id' => $product->id, 'level_id' => $level->id, 'price' => 8.80],
|
||||
['product_id' => $product->id, 'cost_price' => 12],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame(
|
||||
8.80,
|
||||
(float) ProductPriceModel::forProductLevel($product->id, $level->id)->first()->price,
|
||||
'等级价格应已更新'
|
||||
);
|
||||
$this->assertSame('12.00', (string) $product->fresh()->cost_price, '成本价应已更新');
|
||||
|
||||
$this->assertSame(
|
||||
1,
|
||||
NoticeModel::where('user_id', $affectedUser->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'受影响门店用户应收到价格变更通知'
|
||||
NoticeModel::where('user_id', $userA->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'成本价变更影响所有等级售价,门店用户应收到价格变更通知'
|
||||
);
|
||||
$this->assertSame(
|
||||
0,
|
||||
NoticeModel::where('user_id', $unaffectedUser->id)->count(),
|
||||
'无关门店用户不应收到通知'
|
||||
1,
|
||||
NoticeModel::where('user_id', $userB->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'成本价变更影响所有等级售价,门店用户应收到价格变更通知'
|
||||
);
|
||||
}
|
||||
|
||||
/** 价格矩阵:行=商品 × 列=启用等级 */
|
||||
/** 价格矩阵:行=商品 × 列=启用等级,等级格为按上浮比例换算的售价 */
|
||||
public function test_price_matrix_structure(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create();
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 6.00]);
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 20]);
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
|
||||
$response = $this->getJson('/product/goods/priceMatrix');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertNotEmpty($response->json('data.levels'));
|
||||
$levels = $response->json('data.levels');
|
||||
$this->assertNotEmpty($levels);
|
||||
$levelRow = collect($levels)->firstWhere('id', $level->id);
|
||||
$this->assertSame(20.0, (float) $levelRow['percent'], '矩阵等级列应含上浮比例');
|
||||
|
||||
$row = collect($response->json('data.rows'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(6.0, (float) $row['price_' . $level->id]);
|
||||
$this->assertSame(12.0, (float) $row['price_' . $level->id], '矩阵等级格应为换算价:10 元上浮 20% = 12.00');
|
||||
}
|
||||
|
||||
/** 等级上浮比例变更:通知该等级下正常门店用户(不影响其他等级门店) */
|
||||
public function test_level_percent_update_notifies_level_stores(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 10]);
|
||||
$otherLevel = CustomerLevelModel::factory()->create(['percent' => 20]);
|
||||
$user = UserModel::factory()->forStore(
|
||||
StoreModel::factory()->create(['level_id' => $level->id])->id
|
||||
)->create();
|
||||
$otherUser = UserModel::factory()->forStore(
|
||||
StoreModel::factory()->create(['level_id' => $otherLevel->id])->id
|
||||
)->create();
|
||||
|
||||
$this->putJson('/customer/level/' . $level->id, [
|
||||
'name' => $level->name,
|
||||
'percent' => 30,
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame('30.00', (string) $level->fresh()->percent, '上浮比例应已更新');
|
||||
$this->assertSame(
|
||||
1,
|
||||
NoticeModel::where('user_id', $user->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'该等级门店用户应收到价格变更通知'
|
||||
);
|
||||
$this->assertSame(
|
||||
0,
|
||||
NoticeModel::where('user_id', $otherUser->id)->count(),
|
||||
'其他等级门店用户不应收到通知'
|
||||
);
|
||||
}
|
||||
|
||||
/** 上浮比例未变化时不重复通知 */
|
||||
public function test_level_update_without_percent_change_sends_no_notice(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 10]);
|
||||
$user = UserModel::factory()->forStore(
|
||||
StoreModel::factory()->create(['level_id' => $level->id])->id
|
||||
)->create();
|
||||
|
||||
$this->putJson('/customer/level/' . $level->id, [
|
||||
'name' => $level->name . '(改)',
|
||||
'percent' => 10,
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame(0, NoticeModel::where('user_id', $user->id)->count(), '上浮比例未变不应发通知');
|
||||
}
|
||||
|
||||
/** 等级表单:上浮比例校验(负数/超限拒绝) */
|
||||
public function test_level_percent_validation(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/customer/level', ['name' => '负比例等级', 'percent' => -1])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '价格上浮比例不能小于 0');
|
||||
$this->postJson('/customer/level', ['name' => '超比例等级', 'percent' => 1000])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '价格上浮比例不能超过 999.99');
|
||||
|
||||
$this->assertSame(0, CustomerLevelModel::count(), '校验失败不应落库');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
@@ -19,7 +18,7 @@ use App\Models\UserModel;
|
||||
class PurchaseEditTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造采购链路:门店A 2 件 + 门店B 3 件(同一商品,等级价 10.00,成本 20,包规 10斤/箱 → 单价 2.00),
|
||||
* 造采购链路:门店A 2 件 + 门店B 3 件(同一商品,等级上浮 0% → 售价=成本价 10.00,包规 10斤/箱),
|
||||
* 接单后生成采购单
|
||||
*
|
||||
* @return array{0: PurchaseOrderModel, 1: ProductModel, 2: array<int, StoreModel>}
|
||||
@@ -31,11 +30,10 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 20,
|
||||
'cost_price' => 10,
|
||||
'spec' => '10斤/箱',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||
|
||||
foreach ([[$storeA, 2], [$storeB, 3]] as [$store, $qty]) {
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
@@ -70,7 +68,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$this->assertSame($product->id, $row['product_id']);
|
||||
$this->assertSame('10斤/箱', $row['product_spec']);
|
||||
$this->assertSame('斤', $row['unit']);
|
||||
$this->assertSame('20.00', (string) $row['cost_price']);
|
||||
$this->assertSame('10.00', (string) $row['cost_price']);
|
||||
$this->assertSame(5, $row['quantity'], '2+3');
|
||||
|
||||
$cells = $row['cells'];
|
||||
@@ -101,7 +99,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('8.00', (string) $purchase->total_quantity, '5+3');
|
||||
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30');
|
||||
$this->assertSame('160.00', (string) $purchase->actual_amount, '8 包 × 每包成本 20.00');
|
||||
$this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00');
|
||||
}
|
||||
|
||||
/** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×每包成本 重算 */
|
||||
@@ -279,13 +277,14 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('4.500', (string) $purchase->total_weight);
|
||||
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30 订货金额');
|
||||
$this->assertSame('160.00', (string) $purchase->actual_amount, '8 包 × 每包成本 20.00(称重仅参考,不参与金额)');
|
||||
$this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)');
|
||||
}
|
||||
|
||||
/** 单元格一键同步:按商品ID同步档案 + 等级价重算,订货单与采购单汇总级联 */
|
||||
/** 单元格一键同步:按商品ID同步档案 + 等级上浮价重算,订货单与采购单汇总级联 */
|
||||
public function test_cell_sync_refreshes_snapshot_and_cascades(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
// 等级上浮 30%:下单时成本 10.00 → 售价 13.00
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 30]);
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
@@ -293,12 +292,6 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
'spec' => '1斤/袋',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 2]]])
|
||||
@@ -371,11 +364,10 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 20,
|
||||
'cost_price' => 10,
|
||||
'spec' => '10斤/箱',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
foreach ([2, 3] as $qty) {
|
||||
@@ -433,7 +425,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变');
|
||||
$this->assertSame('20.00', (string) $item->fresh()->cost_price);
|
||||
$this->assertSame('10.00', (string) $item->fresh()->cost_price);
|
||||
|
||||
// 下钻明细同步标记不可编辑
|
||||
$this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[0]->id}")
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
@@ -19,17 +18,14 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
|
||||
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价),每包成本 6.00
|
||||
* 门店等级上浮 0%(售价=成本价 6.00),预估成本按成本价合计
|
||||
*/
|
||||
private function seedAcceptedOrders(): ProductModel
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$levelLow = CustomerLevelModel::factory()->create();
|
||||
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '6.00']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.00]);
|
||||
|
||||
foreach ([[$storeA, 3], [$storeA, 3], [$storeB, 4]] as [$store, $qty]) {
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
@@ -91,8 +87,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
@@ -17,23 +16,20 @@ use App\Models\UserModel;
|
||||
class StoreOrderItemTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造门店 + 上架商品(指定成本价)+ 固定等级价 + 门店用户
|
||||
* 造门店 + 上架商品(指定成本价)+ 门店用户;
|
||||
* 等级上浮比例按 price/costPrice 反算(售价 = 成本价 × (100 + percent) / 100)
|
||||
*
|
||||
* @return array{0: StoreModel, 1: ProductModel, 2: UserModel}
|
||||
*/
|
||||
private function makeStoreWithProduct(string $price = '5.00', string $costPrice = '4.00'): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$percent = round(((float) $price / (float) $costPrice - 1) * 100, 2);
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => $percent]);
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $costPrice,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
@@ -58,7 +54,8 @@ class StoreOrderItemTest extends ProcurementTestCase
|
||||
public function test_place_order_snapshots_full_product_info(): void
|
||||
{
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
// 上浮 37.5%:成本 4.00 → 售价 5.50
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 37.5]);
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
@@ -70,11 +67,6 @@ class StoreOrderItemTest extends ProcurementTestCase
|
||||
'shelf_life' => 30,
|
||||
'cost_price' => '4.00',
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.50',
|
||||
]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
@@ -244,18 +236,13 @@ class StoreOrderItemTest extends ProcurementTestCase
|
||||
/** 一键同步:百分比计价等级按最新成本价重算单价与订单总价 */
|
||||
public function test_sync_item_recalculates_percent_price_with_latest_cost(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
// 等级上浮 30%:下单时成本 10.00 → 售价 13.00
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 30]);
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => '10.00',
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
@@ -303,16 +290,8 @@ class StoreOrderItemTest extends ProcurementTestCase
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$cabbage = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '大白菜A']);
|
||||
$potato = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '土豆B']);
|
||||
foreach ([$cabbage, $potato] as $p) {
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $p->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.00',
|
||||
]);
|
||||
}
|
||||
|
||||
$cabbage = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '大白菜A', 'cost_price' => '5.00']);
|
||||
$potato = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '土豆B', 'cost_price' => '5.00']);
|
||||
$this->placeOrder($cabbage, $user);
|
||||
$this->placeOrder($potato, $user);
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离
|
||||
* 小程序下单:等级上浮价快照、服务端重算总价、取消限制、门店数据隔离
|
||||
*/
|
||||
class StoreOrderTest extends ProcurementTestCase
|
||||
{
|
||||
@@ -22,11 +21,9 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => $price,
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
@@ -73,11 +70,11 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
$this->assertSame('10.00', (string) $order->total_amount, '应按等级价 5.00×2 计算,忽略前端金额');
|
||||
}
|
||||
|
||||
/** 商品未设置门店等级价格时拒绝下单 */
|
||||
public function test_product_without_level_price_rejected(): void
|
||||
/** 门店绑定的客户等级被删除时拒绝下单 */
|
||||
public function test_store_level_missing_rejected(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
ProductPriceModel::where('product_id', $product->id)->delete();
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
CustomerLevelModel::whereKey($store->level_id)->delete();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/order', [
|
||||
@@ -140,8 +137,7 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
|
||||
$items = [];
|
||||
foreach (['白菜', '土豆', '番茄', '黄瓜'] as $name) {
|
||||
$product = ProductModel::factory()->create(['name' => $name, 'status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
$product = ProductModel::factory()->create(['name' => $name, 'status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
$items[] = ['product_id' => $product->id, 'quantity' => 2];
|
||||
}
|
||||
$this->postJson('/mini/order', ['items' => $items])->assertJsonPath('success', true);
|
||||
@@ -345,11 +341,7 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
'spec' => '30斤/筐',
|
||||
'unit' => '筐',
|
||||
'image_ids' => (string) $file->id,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.00',
|
||||
'cost_price' => '5.00',
|
||||
]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface PriceMatrixParams {
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 价格矩阵:行=商品,列=启用等级,值=实际销售价(缺失 null);
|
||||
* 行内含 cost_price 与 price_type_{level_id} / percent_{level_id}
|
||||
* A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null);
|
||||
* 仅成本价可编辑
|
||||
*/
|
||||
export async function getPriceMatrix(params?: PriceMatrixParams) {
|
||||
return createAxios<IPriceMatrix>({
|
||||
@@ -22,7 +22,7 @@ export async function getPriceMatrix(params?: PriceMatrixParams) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 批量调价:支持成本价 / 固定价 / 成本百分比三类更新行(提交后给受影响门店生成价格变更通知)
|
||||
* A2 批量调价:批量调整成本价(等级售价随上浮比例联动;提交后给受影响门店生成价格变更通知)
|
||||
*/
|
||||
export async function batchPrice(updates: IBatchPriceUpdate[]) {
|
||||
return createAxios({
|
||||
|
||||
@@ -5,6 +5,8 @@ export default interface ICustomerLevel {
|
||||
id?: number;
|
||||
/** 等级名称 */
|
||||
name?: string;
|
||||
/** 价格上浮比例(%,售价 = 成本价 × (100 + percent) / 100) */
|
||||
percent?: string | number;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
icon_id?: number;
|
||||
|
||||
+12
-27
@@ -2,19 +2,14 @@ import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 商品等级价格行(null = 该等级未设定价格,编辑时移除该行) */
|
||||
/** 商品等级展示价(后端按等级上浮比例换算:售价 = 成本价 × (100 + percent) / 100) */
|
||||
export interface IProductPrice {
|
||||
id?: number;
|
||||
product_id?: number;
|
||||
/** 客户等级ID */
|
||||
level_id?: number;
|
||||
/** 单价:固定价=实际单价;成本百分比=等价固定价 */
|
||||
/** 该等级的价格上浮比例(%) */
|
||||
percent?: string | number;
|
||||
/** 换算后的售价(两位小数字符串) */
|
||||
price?: string | number | null;
|
||||
/** 计价类型:0 固定价(默认) 1 成本百分比 */
|
||||
price_type?: number;
|
||||
/** 成本上浮百分点(price_type=1 时生效,如 30 = 上浮 30%) */
|
||||
percent?: string | number | null;
|
||||
/** 实际销售价(模型换算:百分比 = 成本价 × (100 + percent) / 100) */
|
||||
actual_price?: string | number;
|
||||
level?: { id: number; name: string };
|
||||
}
|
||||
|
||||
@@ -45,7 +40,7 @@ export default interface IProduct {
|
||||
stock?: number;
|
||||
/** 状态 */
|
||||
status?: number;
|
||||
/** 成本价(元,成本百分比计价的基数) */
|
||||
/** 成本价(元,等级上浮计价的基数) */
|
||||
cost_price?: string | number;
|
||||
/** 描述 */
|
||||
remark?: string;
|
||||
@@ -53,7 +48,7 @@ export default interface IProduct {
|
||||
category?: IProductCategory;
|
||||
/** 供应商关联数据 */
|
||||
supplier?: ISupplier;
|
||||
/** 多等级价格 */
|
||||
/** 各启用等级的换算展示价 */
|
||||
prices?: IProductPrice[];
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
@@ -65,7 +60,7 @@ export const PRODUCT_STATUS_MAP: Record<number, { text: string; color: string }>
|
||||
};
|
||||
|
||||
/**
|
||||
* 价格矩阵行:固定列 + price_{level_id}(实际价)/ price_type_{level_id} / percent_{level_id} 动态列
|
||||
* 价格矩阵行:固定列 + price_{level_id}(按等级上浮比例换算的售价)动态列
|
||||
*/
|
||||
export type IPriceMatrixRow = {
|
||||
id: number;
|
||||
@@ -77,27 +72,17 @@ export type IPriceMatrixRow = {
|
||||
} & Record<string, string | number | null | undefined>;
|
||||
|
||||
export interface IPriceMatrix {
|
||||
levels: { id: number; name: string }[];
|
||||
levels: { id: number; name: string; percent: string | number }[];
|
||||
rows: IPriceMatrixRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量调价更新行(三类,可混合同一行):
|
||||
* - 成本行 { product_id, cost_price }
|
||||
* - 固定价行 { product_id, level_id, price_type: 0, price }
|
||||
* - 百分比行 { product_id, level_id, price_type: 1, percent }(price 可选,等价固定价)
|
||||
* 批量调价更新行:{ product_id, cost_price }
|
||||
* (等级售价 = 成本价 × (100 + 等级上浮比例) / 100,调价即调成本价)
|
||||
*/
|
||||
export interface IBatchPriceUpdate {
|
||||
product_id: number;
|
||||
/** 成本价(成本行) */
|
||||
/** 成本价 */
|
||||
cost_price?: number;
|
||||
/** 客户等级ID(等级价格行) */
|
||||
level_id?: number;
|
||||
/** 计价类型:0 固定价(默认) 1 成本百分比 */
|
||||
price_type?: number;
|
||||
/** 固定价 / 百分比行的等价固定价 */
|
||||
price?: number;
|
||||
/** 成本上浮百分点(price_type=1 时必填) */
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ const CustomerLevelPage: React.FC = () => {
|
||||
align: 'center',
|
||||
rules: [{ required: true, message: '请输入等级名称' }],
|
||||
},
|
||||
{
|
||||
title: '价格上浮比例',
|
||||
dataIndex: 'percent',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
tooltip: '该等级售价 = 成本价 × (100 + 上浮比例) / 100',
|
||||
fieldProps: { min: 0, max: 999.99, precision: 2, suffix: '%', placeholder: '如 30 表示成本价上浮 30%' },
|
||||
align: 'center',
|
||||
render: (_, record) => <Tag color="blue">{Number(record.percent ?? 0)}%</Tag>,
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
@@ -111,7 +122,7 @@ const CustomerLevelPage: React.FC = () => {
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>客户等级</Title>
|
||||
<Text type="secondary">同一商品按客户等级显示不同单价,门店绑定等级后小程序端按对应价格展示。</Text>
|
||||
<Text type="secondary">按等级配置价格上浮比例:售价 = 成本价 × (100 + 上浮比例) / 100,门店绑定等级后小程序端按对应价格展示。</Text>
|
||||
</div>
|
||||
<XinTable<ICustomerLevel> {...tableProps} />
|
||||
</>
|
||||
|
||||
@@ -454,7 +454,7 @@ const Index: React.FC = () => {
|
||||
title: t("dashboard.analysis.quantity"),
|
||||
key: "quantity",
|
||||
width: 100,
|
||||
render: (_, record) => `${record.quantity} ${record.unit}`,
|
||||
render: (_, record) => `${record.quantity}`,
|
||||
},
|
||||
{
|
||||
title: t("dashboard.analysis.amount"),
|
||||
|
||||
+88
-280
@@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Card,
|
||||
Drawer,
|
||||
Form, Image,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag, Tree,
|
||||
@@ -14,15 +13,14 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { TableOutlined } from '@ant-design/icons';
|
||||
import type { FormInstance, TableProps } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IProduct from '@/domain/iProduct.ts';
|
||||
import type { IBatchPriceUpdate, IPriceMatrixRow, IProductPrice } from '@/domain/iProduct.ts';
|
||||
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
|
||||
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
|
||||
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
|
||||
import type {IProductCategoryTree} from '@/domain/iProductCategory.ts';
|
||||
import { getLevelOptions } from '@/api/customer/level.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import { getCategoryTree } from '@/api/product/category.ts';
|
||||
@@ -30,14 +28,6 @@ import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 计价类型:0 固定价 / 1 成本百分比(与后端 ProductPriceModel::PRICE_TYPE_* 一致) */
|
||||
const PRICE_TYPE_FIXED = 0;
|
||||
const PRICE_TYPE_PERCENT = 1;
|
||||
const PRICE_TYPE_OPTIONS = [
|
||||
{ value: PRICE_TYPE_FIXED, label: '固定价' },
|
||||
{ value: PRICE_TYPE_PERCENT, label: '成本百分比' },
|
||||
];
|
||||
|
||||
/** 四舍五入保留两位 */
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
@@ -57,122 +47,13 @@ const markParentUnselectable = (nodes: IProductCategoryTree[]): IProductCategory
|
||||
children: node.children?.length ? markParentUnselectable(node.children) : node.children,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框;
|
||||
* 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100))
|
||||
*/
|
||||
const LevelPriceFields: React.FC<{
|
||||
form: FormInstance;
|
||||
levels: ICustomerLevel[];
|
||||
}> = ({ form, levels }) => {
|
||||
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
|
||||
const costPrice = Number(Form.useWatch<string | number | undefined>('cost_price', form) ?? 0);
|
||||
|
||||
if (levels.length === 0) {
|
||||
return (
|
||||
<Text type="secondary">
|
||||
暂无可配置的客户等级,请先到「客户等级」中新增
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const updateRow = (levelId: number, patch: Partial<IProductPrice>) => {
|
||||
const next = prices.filter((p) => p.level_id !== levelId);
|
||||
next.push({ ...patch, level_id: levelId });
|
||||
form.setFieldValue('prices', next);
|
||||
};
|
||||
|
||||
const removeRow = (levelId: number) => {
|
||||
form.setFieldValue('prices', prices.filter((p) => p.level_id !== levelId));
|
||||
};
|
||||
|
||||
/** 切换计价类型:按成本价自动换算(成本未设置时百分比计价不可用) */
|
||||
const onTypeChange = (levelId: number, row: IProductPrice | undefined, type: number) => {
|
||||
if (type === PRICE_TYPE_PERCENT) {
|
||||
if (!(costPrice > 0)) {
|
||||
message.warning('请先在商品信息中设置成本价,才能按成本百分比计价');
|
||||
return; // Select 为受控组件,未更新表单值即回弹
|
||||
}
|
||||
const price = Number(row?.price ?? 0);
|
||||
updateRow(levelId, {
|
||||
price_type: type,
|
||||
percent: price > 0 ? round2((price / costPrice - 1) * 100) : null,
|
||||
});
|
||||
} else {
|
||||
const percent = Number(row?.percent ?? 0);
|
||||
updateRow(levelId, {
|
||||
price_type: type,
|
||||
price: costPrice > 0 && percent > 0 ? round2(costPrice * (1 + percent / 100)) : (row?.price ?? null),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
{levels.map((level) => {
|
||||
if (level.id == null) return null;
|
||||
const row = prices.find((p) => p.level_id === level.id);
|
||||
const type = row?.price_type ?? PRICE_TYPE_FIXED;
|
||||
return (
|
||||
<Space key={level.id} wrap={false}>
|
||||
<Text style={{ width: 60, display: 'inline-block', textAlign: 'right' }}>{level.name}</Text>
|
||||
<Select
|
||||
size="middle"
|
||||
style={{ width: 110 }}
|
||||
value={type}
|
||||
options={PRICE_TYPE_OPTIONS}
|
||||
onChange={(v) => onTypeChange(level.id as number, row, v as number)}
|
||||
/>
|
||||
{type === PRICE_TYPE_PERCENT ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
suffix={'%'}
|
||||
placeholder="上浮百分点"
|
||||
value={(row?.percent as number | null) ?? null}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
removeRow(level.id as number);
|
||||
return;
|
||||
}
|
||||
// 同步维护等价固定价 price,保证提交给后端的 price 与 percent 一致
|
||||
updateRow(level.id as number, {
|
||||
price_type: PRICE_TYPE_PERCENT,
|
||||
percent: v,
|
||||
price: costPrice > 0 ? round2(costPrice * (1 + v / 100)) : row?.price,
|
||||
});
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
) : (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
suffix={'¥'}
|
||||
placeholder="未设定"
|
||||
value={(row?.price as number | null) ?? null}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
removeRow(level.id as number);
|
||||
return;
|
||||
}
|
||||
updateRow(level.id as number, { price_type: PRICE_TYPE_FIXED, price: v });
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价)
|
||||
*
|
||||
* 价格体系:售价 = 成本价 × (100 + 客户等级上浮比例) / 100;
|
||||
* 等级上浮比例在「客户等级」中维护,本页价格矩阵仅批量调整成本价。
|
||||
*/
|
||||
const ProductGoodsPage: React.FC = () => {
|
||||
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
|
||||
// 商品表单专用分类树:父分类禁选,仅末级可选(侧栏筛选/价格矩阵仍用原始树)
|
||||
@@ -196,15 +77,10 @@ const ProductGoodsPage: React.FC = () => {
|
||||
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [matrixKeyword, setMatrixKeyword] = useState('');
|
||||
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
||||
/** 跨页未保存的等级调价:`${productId}:${levelId}` → { price, price_type }(用 ref 避免异步闭包读到旧值) */
|
||||
const matrixDirtyRef = useRef<Record<string, { price: number | null; price_type: number }>>({});
|
||||
/** 跨页未保存的成本价:productId → cost(null 视为未修改,不提交) */
|
||||
const costDirtyRef = useRef<Record<number, number | null>>({});
|
||||
/** 服务端原始成本价:productId → cost(loadMatrix 填充;跨页百分比行保存时反算 percent 用) */
|
||||
const serverCostRef = useRef<Record<number, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
|
||||
}, []);
|
||||
@@ -224,29 +100,24 @@ const ProductGoodsPage: React.FC = () => {
|
||||
pageSize,
|
||||
});
|
||||
const rows = res.data.data?.rows ?? [];
|
||||
// 服务端原始成本价快照(供跨页百分比行保存时反算 percent)
|
||||
rows.forEach((row) => {
|
||||
serverCostRef.current[row.id] = Number(row.cost_price ?? 0);
|
||||
});
|
||||
// 叠加跨页未保存的修改,保证翻页后输入值不回退
|
||||
const levels = res.data.data?.levels ?? [];
|
||||
// 叠加跨页未保存的成本价修改,并重算各等级展示价,保证翻页后输入值不回退
|
||||
const merged = rows.map((row) => {
|
||||
const next = { ...row };
|
||||
// 成本价修改
|
||||
if (costDirtyRef.current[row.id] !== undefined) {
|
||||
next.cost_price = costDirtyRef.current[row.id];
|
||||
}
|
||||
// 等级格修改(快照含 price_type)
|
||||
Object.entries(matrixDirtyRef.current).forEach(([key, snap]) => {
|
||||
const [pid, lid] = key.split(':');
|
||||
if (String(row.id) === pid) {
|
||||
next[`price_${lid}`] = snap.price;
|
||||
}
|
||||
const dirty = costDirtyRef.current[row.id];
|
||||
if (dirty === undefined) return row;
|
||||
const next: IPriceMatrixRow = { ...row, cost_price: dirty };
|
||||
const cost = Number(dirty ?? 0);
|
||||
levels.forEach((level) => {
|
||||
if (level.id == null) return;
|
||||
next[`price_${level.id}`] = cost > 0
|
||||
? round2(cost * (1 + Number(level.percent ?? 0) / 100))
|
||||
: null;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setMatrixRows(merged);
|
||||
setMatrixTotal(res.data.data?.total ?? 0);
|
||||
setMatrixLevels(res.data.data?.levels ?? []);
|
||||
setMatrixLevels(levels);
|
||||
} finally {
|
||||
setMatrixLoading(false);
|
||||
}
|
||||
@@ -259,23 +130,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
loadMatrix('', undefined, 1, 20);
|
||||
};
|
||||
|
||||
const onMatrixPriceChange = (
|
||||
productId: number,
|
||||
levelId: number,
|
||||
value: number | null
|
||||
) => {
|
||||
setMatrixRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.id !== productId) return row;
|
||||
// 快照该格的计价类型(读当前行数据,避免闭包旧 state)
|
||||
const type = Number(row[`price_type_${levelId}`] ?? PRICE_TYPE_FIXED);
|
||||
matrixDirtyRef.current[`${productId}:${levelId}`] = { price: value, price_type: type };
|
||||
return { ...row, [`price_${levelId}`]: value };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/** 修改成本价:联动重算「未被手工修改过」的百分比格显示值(percent 取服务端快照) */
|
||||
/** 修改成本价:联动重算各等级展示价(上浮比例取 levels 配置) */
|
||||
const onMatrixCostChange = (productId: number, value: number | null) => {
|
||||
costDirtyRef.current[productId] = value;
|
||||
setMatrixRows((prev) =>
|
||||
@@ -283,54 +138,24 @@ const ProductGoodsPage: React.FC = () => {
|
||||
if (row.id !== productId) return row;
|
||||
const next: IPriceMatrixRow = { ...row, cost_price: value };
|
||||
const cost = Number(value ?? 0);
|
||||
Object.keys(next).forEach((key) => {
|
||||
if (!key.startsWith('percent_')) return;
|
||||
const lid = key.replace('percent_', '');
|
||||
if (matrixDirtyRef.current[`${productId}:${lid}`]) return; // 已手工改过,不覆盖
|
||||
if (Number(next[`price_type_${lid}`]) !== PRICE_TYPE_PERCENT) return;
|
||||
const percent = Number(next[key] ?? 0);
|
||||
next[`price_${lid}`] = cost > 0 ? round2(cost * (1 + percent / 100)) : null;
|
||||
matrixLevels.forEach((level) => {
|
||||
if (level.id == null) return;
|
||||
next[`price_${level.id}`] = cost > 0
|
||||
? round2(cost * (1 + Number(level.percent ?? 0) / 100))
|
||||
: null;
|
||||
});
|
||||
return next;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
|
||||
/** 提交所有跨页未保存的成本价调整(null 视为清除,不提交) */
|
||||
const saveMatrix = async () => {
|
||||
const updates: IBatchPriceUpdate[] = [];
|
||||
// 成本价行
|
||||
Object.entries(costDirtyRef.current).forEach(([pid, cost]) => {
|
||||
if (cost === null || cost === undefined) return;
|
||||
updates.push({ product_id: Number(pid), cost_price: cost });
|
||||
});
|
||||
// 等级价格行(按快照计价类型组装:百分比格按「当前成本价」反算上浮百分点)
|
||||
for (const [key, snap] of Object.entries(matrixDirtyRef.current)) {
|
||||
if (snap.price === null || snap.price === undefined) continue;
|
||||
const [pid, lid] = key.split(':');
|
||||
const productId = Number(pid);
|
||||
if (snap.price_type === PRICE_TYPE_PERCENT) {
|
||||
const cost = costDirtyRef.current[productId] ?? serverCostRef.current[productId] ?? 0;
|
||||
if (!(cost > 0)) {
|
||||
message.error(`商品 #${productId} 未设置成本价,无法保存百分比价格`);
|
||||
return;
|
||||
}
|
||||
updates.push({
|
||||
product_id: productId,
|
||||
level_id: Number(lid),
|
||||
price_type: PRICE_TYPE_PERCENT,
|
||||
percent: round2((snap.price / cost - 1) * 100),
|
||||
price: snap.price, // 等价固定价
|
||||
});
|
||||
} else {
|
||||
updates.push({
|
||||
product_id: productId,
|
||||
level_id: Number(lid),
|
||||
price_type: PRICE_TYPE_FIXED,
|
||||
price: snap.price,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
message.info('没有需要保存的价格调整');
|
||||
return;
|
||||
@@ -338,8 +163,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
await batchPrice(updates);
|
||||
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
||||
matrixDirtyRef.current = {};
|
||||
message.success(`已更新 ${updates.length} 件商品成本价,各等级售价已按上浮比例联动,受影响门店将收到通知`);
|
||||
costDirtyRef.current = {};
|
||||
await loadMatrix();
|
||||
} finally {
|
||||
@@ -385,22 +209,11 @@ const ProductGoodsPage: React.FC = () => {
|
||||
key: `price_${level.id}`,
|
||||
width: 150,
|
||||
render: (_: unknown, row: IPriceMatrixRow) => {
|
||||
const isPercent = Number(row[`price_type_${level.id}`] ?? PRICE_TYPE_FIXED) === PRICE_TYPE_PERCENT;
|
||||
const percent = Number(row[`percent_${level.id}`] ?? 0);
|
||||
const price = row[`price_${level.id}`];
|
||||
return (
|
||||
<div>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={row[`price_${level.id}`] as number | null}
|
||||
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
|
||||
className="w-32"
|
||||
/>
|
||||
{isPercent && (
|
||||
<div className="text-xs text-gray-400">成本上浮 {percent}%</div>
|
||||
)}
|
||||
<div>{price != null ? `¥${price}` : <Text type="secondary">未设成本价</Text>}</div>
|
||||
<div className="text-xs text-gray-400">成本上浮 {Number(level.percent ?? 0)}%</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -473,6 +286,19 @@ const ProductGoodsPage: React.FC = () => {
|
||||
hideInTable: true,
|
||||
colProps: { span: 24 },
|
||||
},
|
||||
{
|
||||
title: '成本价',
|
||||
dataIndex: 'cost_price',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
tooltip: '各等级售价 = 成本价 × (100 + 等级上浮比例) / 100',
|
||||
fieldProps: { min: 0, precision: 2, prefix: '¥' },
|
||||
render: (_, record) => {
|
||||
const cost = Number(record.cost_price ?? 0);
|
||||
return cost > 0 ? `¥${record.cost_price}` : <Text type="secondary">未设置</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '规格/包规',
|
||||
dataIndex: 'spec',
|
||||
@@ -488,6 +314,14 @@ const ProductGoodsPage: React.FC = () => {
|
||||
initialValue: '斤',
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
fieldProps: { min: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category_id',
|
||||
@@ -522,65 +356,6 @@ const ProductGoodsPage: React.FC = () => {
|
||||
},
|
||||
render: (_, record) => record.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '图文详情',
|
||||
dataIndex: 'content',
|
||||
valueType: 'richText',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
colProps: { span: 24 },
|
||||
fieldProps: {
|
||||
height: 400,
|
||||
groupId: 11,
|
||||
placeholder: '输入商品图文详情,支持插入图片',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本价',
|
||||
dataIndex: 'cost_price',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
fieldProps: { min: 0, precision: 2, prefix: '¥' },
|
||||
render: (_, record) => {
|
||||
const cost = Number(record.cost_price ?? 0);
|
||||
return cost > 0 ? `¥${record.cost_price}` : <Text type="secondary">未设置</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '等级价格',
|
||||
dataIndex: 'prices',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 420,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
{record.prices?.length
|
||||
? record.prices.map((p) => (
|
||||
<Tag
|
||||
key={p.id}
|
||||
color="geekblue"
|
||||
title={Number(p.price_type) === PRICE_TYPE_PERCENT ? `按成本价上浮 ${p.percent}%` : undefined}
|
||||
>
|
||||
{p.level?.name ?? `等级${p.level_id}`}
|
||||
<span style={{color: 'red', marginLeft: 5 }}>
|
||||
¥{p.actual_price ?? p.price ?? '未设定'}
|
||||
</span>
|
||||
</Tag>
|
||||
))
|
||||
: '-'}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
fieldProps: { min: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -599,12 +374,44 @@ const ProductGoodsPage: React.FC = () => {
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '等级价格设置',
|
||||
dataIndex: 'prices',
|
||||
hideInTable: true,
|
||||
title: '图文详情',
|
||||
dataIndex: 'content',
|
||||
valueType: 'richText',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
colProps: { span: 24 },
|
||||
fieldRender: (form) => <LevelPriceFields form={form} levels={levels} />,
|
||||
fieldProps: {
|
||||
height: 400,
|
||||
groupId: 11,
|
||||
placeholder: '输入商品图文详情,支持插入图片',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
title: '等级价格',
|
||||
dataIndex: 'prices',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 420,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
{record.prices?.length
|
||||
? record.prices.map((p) => (
|
||||
<Tag
|
||||
key={p.level_id}
|
||||
color="geekblue"
|
||||
title={`按成本价上浮 ${p.percent ?? 0}%`}
|
||||
>
|
||||
{p.level?.name ?? `等级${p.level_id}`}
|
||||
<span style={{color: 'red', marginLeft: 5 }}>
|
||||
¥{p.price ?? '未设定'}
|
||||
</span>
|
||||
</Tag>
|
||||
))
|
||||
: '-'}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
@@ -652,7 +459,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
<div className="mb-5">
|
||||
<Title level={3}>商品列表</Title>
|
||||
<Text type="secondary">
|
||||
商品档案与多等级价格体系;「价格矩阵」支持按等级批量调价,调价后自动通知受影响门店。
|
||||
商品售价按客户等级上浮比例自动换算(成本价 × (100 + 上浮比例) / 100);「价格矩阵」支持批量调整成本价,调价后自动通知受影响门店。
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -717,6 +524,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
loadMatrix(v, matrixCategory, 1, matrixPageSize);
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">等级列按上浮比例自动换算,仅成本价可编辑</Text>
|
||||
</Space>
|
||||
<Table<IPriceMatrixRow>
|
||||
rowKey="id"
|
||||
|
||||
Reference in New Issue
Block a user