价格增加百分比上浮
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -45,11 +45,12 @@ class CartController extends BaseMiniController
|
|||||||
if ($product === null) {
|
if ($product === null) {
|
||||||
throw new RepositoryException('商品不存在或已下架,请刷新后重试');
|
throw new RepositoryException('商品不存在或已下架,请刷新后重试');
|
||||||
}
|
}
|
||||||
$price = ProductPriceModel::query()
|
// 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购)
|
||||||
|
$hasPrice = ProductPriceModel::query()
|
||||||
->where('product_id', $productId)
|
->where('product_id', $productId)
|
||||||
->where('level_id', $store->level_id)
|
->where('level_id', $store->level_id)
|
||||||
->value('price');
|
->exists();
|
||||||
if ($price === null) {
|
if (! $hasPrice) {
|
||||||
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法加购');
|
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法加购');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +116,11 @@ class CartController extends BaseMiniController
|
|||||||
->unique()->values()->all();
|
->unique()->values()->all();
|
||||||
|
|
||||||
$products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
|
$products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
|
||||||
$prices = ProductPriceModel::query()
|
$priceRows = ProductPriceModel::query()
|
||||||
->where('level_id', $store->level_id)
|
->where('level_id', $store->level_id)
|
||||||
->whereIn('product_id', $productIds)
|
->whereIn('product_id', $productIds)
|
||||||
->pluck('price', 'product_id');
|
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||||
|
->keyBy('product_id');
|
||||||
|
|
||||||
// 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。
|
// 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。
|
||||||
// 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值
|
// 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值
|
||||||
@@ -140,7 +142,16 @@ class CartController extends BaseMiniController
|
|||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$product = $products->get($row->product_id);
|
$product = $products->get($row->product_id);
|
||||||
$productOn = $product !== null && $product->status === ProductModel::STATUS_ON;
|
$productOn = $product !== null && $product->status === ProductModel::STATUS_ON;
|
||||||
$price = $prices->get($row->product_id); // string|null
|
// 实际价(百分比计价行按成本价上浮换算);未设等级价为 null
|
||||||
|
$priceRow = $priceRows->get($row->product_id);
|
||||||
|
$price = $priceRow === null
|
||||||
|
? null
|
||||||
|
: ProductPriceModel::calcActualPrice(
|
||||||
|
(int) $priceRow->price_type,
|
||||||
|
$priceRow->price,
|
||||||
|
$priceRow->percent,
|
||||||
|
(float) ($product?->cost_price ?? 0),
|
||||||
|
);
|
||||||
$buyable = $productOn && $price !== null;
|
$buyable = $productOn && $price !== null;
|
||||||
$quantity = (string) $row->quantity;
|
$quantity = (string) $row->quantity;
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ class OrderController extends BaseMiniController
|
|||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
$prices = ProductPriceModel::query()
|
$priceRows = ProductPriceModel::query()
|
||||||
->where('level_id', $store->level_id)
|
->where('level_id', $store->level_id)
|
||||||
->whereIn('product_id', $productIds)
|
->whereIn('product_id', $productIds)
|
||||||
->pluck('price', 'product_id');
|
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||||
|
->keyBy('product_id');
|
||||||
|
|
||||||
$totalQuantity = '0';
|
$totalQuantity = '0';
|
||||||
$totalAmount = '0';
|
$totalAmount = '0';
|
||||||
@@ -62,11 +63,18 @@ class OrderController extends BaseMiniController
|
|||||||
if ($product === null) {
|
if ($product === null) {
|
||||||
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
|
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
|
||||||
}
|
}
|
||||||
if (! isset($prices[$productId])) {
|
if (! isset($priceRows[$productId])) {
|
||||||
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
|
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
|
||||||
}
|
}
|
||||||
|
|
||||||
$price = (string) $prices[$productId];
|
// 实际价(百分比计价行按成本价上浮换算,$products 已含 cost_price)
|
||||||
|
$priceRow = $priceRows[$productId];
|
||||||
|
$price = ProductPriceModel::calcActualPrice(
|
||||||
|
(int) $priceRow->price_type,
|
||||||
|
$priceRow->price,
|
||||||
|
$priceRow->percent,
|
||||||
|
$product->cost_price,
|
||||||
|
);
|
||||||
$quantity = (string) $row['quantity'];
|
$quantity = (string) $row['quantity'];
|
||||||
$amount = bcmul($price, $quantity, 2);
|
$amount = bcmul($price, $quantity, 2);
|
||||||
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
||||||
|
|||||||
@@ -88,9 +88,10 @@ class ProductController extends BaseMiniController
|
|||||||
->paginate($pageSize)
|
->paginate($pageSize)
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
// 扁平化价格:prices[0].price → price(未设等级价为 null)
|
// 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;未设等级价为 null)
|
||||||
|
// unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤
|
||||||
foreach ($data['data'] as &$row) {
|
foreach ($data['data'] as &$row) {
|
||||||
$row['price'] = $row['prices'][0]['price'] ?? null;
|
$row['price'] = $row['prices'][0]['actual_price'] ?? null;
|
||||||
unset($row['prices']);
|
unset($row['prices']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class ProductController extends BaseController
|
|||||||
|
|
||||||
protected array $quickSearchField = ['name', 'spec'];
|
protected array $quickSearchField = ['name', 'spec'];
|
||||||
|
|
||||||
/** A1 商品列表(含分类/供应商/各等级价格) */
|
/** A1 商品列表(含分类/供应商/各等级价格;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||||||
#[GetRoute(authorize: 'query')]
|
#[GetRoute(authorize: 'query')]
|
||||||
public function query(Request $request): JsonResponse
|
public function query(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -50,9 +50,9 @@ class ProductController extends BaseController
|
|||||||
)
|
)
|
||||||
->orderBy('sort')
|
->orderBy('sort')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate($pageSize)
|
->paginate($pageSize);
|
||||||
->toArray();
|
$data->getCollection()->makeVisible('cost_price');
|
||||||
return $this->success($data);
|
return $this->success($data->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传商品分类图片文件 */
|
/** 上传商品分类图片文件 */
|
||||||
@@ -85,6 +85,8 @@ class ProductController extends BaseController
|
|||||||
'product_id' => $product->id,
|
'product_id' => $product->id,
|
||||||
'level_id' => (int) $row['level_id'],
|
'level_id' => (int) $row['level_id'],
|
||||||
'price' => $row['price'],
|
'price' => $row['price'],
|
||||||
|
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
||||||
|
'percent' => $row['percent'] ?? 0,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return $product;
|
return $product;
|
||||||
@@ -114,7 +116,11 @@ class ProductController extends BaseController
|
|||||||
$levelIds[] = $levelId;
|
$levelIds[] = $levelId;
|
||||||
ProductPriceModel::updateOrCreate(
|
ProductPriceModel::updateOrCreate(
|
||||||
['product_id' => $product->id, 'level_id' => $levelId],
|
['product_id' => $product->id, 'level_id' => $levelId],
|
||||||
['price' => $row['price']],
|
[
|
||||||
|
'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->prices()->whereNotIn('level_id', $levelIds)->delete();
|
||||||
@@ -140,12 +146,14 @@ class ProductController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),列=全部启用等级,值=price(缺失为 null)
|
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
||||||
|
* 列=全部启用等级,值=实际销售价(缺失为 null);行内含 cost_price 与每等级
|
||||||
|
* price_type_{levelId} / percent_{levelId},供前端判断计价类型与联动重算
|
||||||
*/
|
*/
|
||||||
#[GetRoute('/priceMatrix', 'query')]
|
#[GetRoute('/priceMatrix', 'query')]
|
||||||
public function priceMatrix(Request $request): JsonResponse
|
public function priceMatrix(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = ProductModel::query()->with('prices:id,product_id,level_id,price');
|
$query = ProductModel::query()->with('prices:id,product_id,level_id,price,price_type,percent');
|
||||||
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
|
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
|
||||||
$query->where('category_id', $categoryId);
|
$query->where('category_id', $categoryId);
|
||||||
}
|
}
|
||||||
@@ -172,11 +180,20 @@ class ProductController extends BaseController
|
|||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'spec' => $product->spec,
|
'spec' => $product->spec,
|
||||||
'unit' => $product->unit,
|
'unit' => $product->unit,
|
||||||
|
'cost_price' => (float) $product->cost_price,
|
||||||
];
|
];
|
||||||
foreach ($levels as $level) {
|
foreach ($levels as $level) {
|
||||||
$row['price_' . $level->id] = isset($priceMap[$level->id])
|
$price = $priceMap[$level->id] ?? null;
|
||||||
? (float) $priceMap[$level->id]->price
|
$row['price_' . $level->id] = $price
|
||||||
|
? (float) ProductPriceModel::calcActualPrice(
|
||||||
|
(int) $price->price_type,
|
||||||
|
$price->price,
|
||||||
|
$price->percent,
|
||||||
|
$product->cost_price,
|
||||||
|
)
|
||||||
: null;
|
: 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 $row;
|
||||||
});
|
});
|
||||||
@@ -189,7 +206,8 @@ class ProductController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A2 批量调价:事务写入,写完后给受影响门店生成 Notice(type=price)
|
* A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入,
|
||||||
|
* 写完后给受影响门店生成 Notice(type=price)
|
||||||
*/
|
*/
|
||||||
#[PutRoute('/batchPrice', 'batchPrice')]
|
#[PutRoute('/batchPrice', 'batchPrice')]
|
||||||
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
||||||
@@ -200,28 +218,52 @@ class ProductController extends BaseController
|
|||||||
$productIds = [];
|
$productIds = [];
|
||||||
$levelIds = [];
|
$levelIds = [];
|
||||||
foreach ($updates as $row) {
|
foreach ($updates as $row) {
|
||||||
ProductPriceModel::updateOrCreate(
|
$productId = (int) $row['product_id'];
|
||||||
['product_id' => (int) $row['product_id'], 'level_id' => (int) $row['level_id']],
|
$productIds[$productId] = true;
|
||||||
['price' => $row['price']],
|
|
||||||
);
|
// 分支1:成本价更新(百分比计价的基数,可与等级价行同在一行)
|
||||||
$productIds[(int) $row['product_id']] = true;
|
if (array_key_exists('cost_price', $row) && $row['cost_price'] !== null) {
|
||||||
$levelIds[(int) $row['level_id']] = true;
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成本价变更只影响「该商品下百分比计价」的等级;受影响门店等级 = 等级更新行 ∪ 百分比行等级
|
||||||
|
$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))
|
$productNames = ProductModel::whereIn('id', array_keys($productIds))
|
||||||
->pluck('name')
|
->pluck('name')
|
||||||
->implode('、');
|
->implode('、');
|
||||||
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
|
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
|
||||||
|
|
||||||
// 受影响门店:客户等级在本次调价等级范围内的正常门店,通知其绑定的正常用户
|
// 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户
|
||||||
$userIds = UserModel::query()
|
$userIds = UserModel::query()
|
||||||
->where('type', UserModel::TYPE_STORE)
|
->where('type', UserModel::TYPE_STORE)
|
||||||
->where('status', UserModel::STATUS_NORMAL)
|
->where('status', UserModel::STATUS_NORMAL)
|
||||||
->whereIn('store_id', function ($q) use ($levelIds) {
|
->whereIn('store_id', function ($q) use ($percentLevelIds) {
|
||||||
$q->select('id')
|
$q->select('id')
|
||||||
->from('store')
|
->from('store')
|
||||||
->where('status', StoreModel::STATUS_NORMAL)
|
->where('status', StoreModel::STATUS_NORMAL)
|
||||||
->whereIn('level_id', array_keys($levelIds));
|
->whereIn('level_id', $percentLevelIds);
|
||||||
})
|
})
|
||||||
->pluck('id');
|
->pluck('id');
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Product;
|
namespace App\Http\Requests\Product;
|
||||||
|
|
||||||
|
use App\Models\ProductPriceModel;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量调价 验证(A2 价格矩阵编辑提交)
|
* 批量调价 验证(A2 价格矩阵编辑提交)
|
||||||
|
*
|
||||||
|
* updates 每行支持三类更新(可混合同一行):
|
||||||
|
* 成本行 {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
|
class BatchPriceRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -16,11 +24,54 @@ class BatchPriceRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'updates' => 'required|array|min:1',
|
'updates' => 'required|array|min:1',
|
||||||
'updates.*.product_id' => 'required|integer|exists:product,id',
|
'updates.*.product_id' => 'required|integer|exists:product,id',
|
||||||
'updates.*.level_id' => 'required|integer|exists:customer_level,id',
|
'updates.*.cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||||
'updates.*.price' => 'required|numeric|min:0',
|
'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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行级交叉校验(在 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
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -28,11 +79,15 @@ class BatchPriceRequest extends BaseFormRequest
|
|||||||
'updates.min' => '请至少提交一条价格调整',
|
'updates.min' => '请至少提交一条价格调整',
|
||||||
'updates.*.product_id.required' => '调价行缺少商品',
|
'updates.*.product_id.required' => '调价行缺少商品',
|
||||||
'updates.*.product_id.exists' => '商品不存在',
|
'updates.*.product_id.exists' => '商品不存在',
|
||||||
'updates.*.level_id.required' => '调价行缺少客户等级',
|
'updates.*.cost_price.numeric' => '成本价必须为数字',
|
||||||
|
'updates.*.cost_price.min' => '成本价不能小于 0',
|
||||||
'updates.*.level_id.exists' => '客户等级不存在',
|
'updates.*.level_id.exists' => '客户等级不存在',
|
||||||
'updates.*.price.required' => '调价行缺少单价',
|
'updates.*.price_type.in' => '计价类型不正确',
|
||||||
'updates.*.price.numeric' => '单价必须为数字',
|
'updates.*.price.numeric' => '单价必须为数字',
|
||||||
'updates.*.price.min' => '单价不能小于 0',
|
'updates.*.price.min' => '单价不能小于 0',
|
||||||
|
'updates.*.percent.numeric' => '上浮百分点必须为数字',
|
||||||
|
'updates.*.percent.min' => '上浮百分点不能小于 0',
|
||||||
|
'updates.*.percent.max' => '上浮百分点不能超过 999.99',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
namespace App\Http\Requests\Product;
|
namespace App\Http\Requests\Product;
|
||||||
|
|
||||||
use App\Models\ProductCategoryModel;
|
use App\Models\ProductCategoryModel;
|
||||||
|
use App\Models\ProductPriceModel;
|
||||||
use App\Models\SupplierModel;
|
use App\Models\SupplierModel;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Validation\Rules\Exists;
|
use Illuminate\Validation\Rules\Exists;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
use Modules\SystemTool\Models\SysFileModel;
|
use Modules\SystemTool\Models\SysFileModel;
|
||||||
|
|
||||||
@@ -30,13 +33,34 @@ class ProductFormRequest extends BaseFormRequest
|
|||||||
'shelf_life' => 'nullable|integer|min:0',
|
'shelf_life' => 'nullable|integer|min:0',
|
||||||
'stock' => 'nullable|integer|min:0',
|
'stock' => 'nullable|integer|min:0',
|
||||||
'status' => 'nullable|integer|in:0,1',
|
'status' => 'nullable|integer|in:0,1',
|
||||||
|
'cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||||
'remark' => 'nullable|string|max:255',
|
'remark' => 'nullable|string|max:255',
|
||||||
'prices' => 'nullable|array',
|
'prices' => 'nullable|array',
|
||||||
'prices.*.level_id' => 'required|integer|exists:customer_level,id',
|
'prices.*.level_id' => 'required|integer|exists:customer_level,id',
|
||||||
'prices.*.price' => 'required|numeric|min:0',
|
'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
|
||||||
|
{
|
||||||
|
return function (Validator $validator): void {
|
||||||
|
$data = (array) $validator->getData();
|
||||||
|
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", '按成本百分比计价时必须填写上浮百分点');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -51,6 +75,12 @@ class ProductFormRequest extends BaseFormRequest
|
|||||||
'prices.*.price.required' => '价格行缺少单价',
|
'prices.*.price.required' => '价格行缺少单价',
|
||||||
'prices.*.price.numeric' => '单价必须为数字',
|
'prices.*.price.numeric' => '单价必须为数字',
|
||||||
'prices.*.price.min' => '单价不能小于 0',
|
'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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ProductModel extends Model
|
|||||||
'shelf_life',
|
'shelf_life',
|
||||||
'stock',
|
'stock',
|
||||||
'status',
|
'status',
|
||||||
|
'cost_price',
|
||||||
'remark',
|
'remark',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,12 +48,19 @@ class ProductModel extends Model
|
|||||||
'stock' => 'integer',
|
'stock' => 'integer',
|
||||||
'sort' => 'integer',
|
'sort' => 'integer',
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
|
'cost_price' => 'decimal:2',
|
||||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $appends = ['images_arr'];
|
protected $appends = ['images_arr'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成本价属商业敏感数据,默认不随 toArray 输出(防止泄漏到小程序/供应商端);
|
||||||
|
* 后台管理接口需在查询结果上调用 makeVisible('cost_price') 恢复。
|
||||||
|
*/
|
||||||
|
protected $hidden = ['cost_price'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 封面图片
|
* 封面图片
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,11 +8,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id)
|
* 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id)
|
||||||
|
*
|
||||||
|
* 计价类型:固定价(price 即实际单价)或成本百分比(实际单价 = 成本价 × (100 + percent) / 100)
|
||||||
*/
|
*/
|
||||||
class ProductPriceModel extends Model
|
class ProductPriceModel extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/** 计价类型:固定价 */
|
||||||
|
public const int PRICE_TYPE_FIXED = 0;
|
||||||
|
/** 计价类型:成本百分比(按成本价上浮 percent 百分点) */
|
||||||
|
public const int PRICE_TYPE_PERCENT = 1;
|
||||||
|
|
||||||
protected $table = 'product_price';
|
protected $table = 'product_price';
|
||||||
protected $primaryKey = 'id';
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
@@ -20,14 +27,21 @@ class ProductPriceModel extends Model
|
|||||||
'product_id',
|
'product_id',
|
||||||
'level_id',
|
'level_id',
|
||||||
'price',
|
'price',
|
||||||
|
'price_type',
|
||||||
|
'percent',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'product_id' => 'integer',
|
'product_id' => 'integer',
|
||||||
'level_id' => 'integer',
|
'level_id' => 'integer',
|
||||||
'price' => 'decimal:2',
|
'price' => 'decimal:2',
|
||||||
|
'price_type' => 'integer',
|
||||||
|
'percent' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 序列化时附带实际销售价(后台列表/小程序列表直接展示) */
|
||||||
|
protected $appends = ['actual_price'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 所属商品
|
* 所属商品
|
||||||
*/
|
*/
|
||||||
@@ -51,4 +65,44 @@ class ProductPriceModel extends Model
|
|||||||
{
|
{
|
||||||
return $query->where('product_id', $productId)->where('level_id', $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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
* 流程(事务内):
|
* 流程(事务内):
|
||||||
* 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等)
|
* 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等)
|
||||||
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
|
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
|
||||||
* 3. 估算单价 = 该商品最低等级价(product_price MIN),amount = quantity × 估算单价
|
* 3. 估算单价 = 该商品最低实际等级价(按计价类型换算后取 min,PHP 侧兼容 MySQL/SQLite),amount = quantity × 估算单价
|
||||||
* 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount)
|
* 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount)
|
||||||
* 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号
|
* 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号
|
||||||
* 6. 源订单批量回写 status = 已汇总
|
* 6. 源订单批量回写 status = 已汇总
|
||||||
@@ -78,12 +78,29 @@ class PurchaseGenerateService
|
|||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
// 3. 估算单价 = 最低等级价
|
// 3. 估算单价 = 最低实际等级价(逐行按计价类型换算后取 min;数量级 = 当日 SKU × 等级,可控)
|
||||||
$minPrices = ProductPriceModel::query()
|
$priceRows = ProductPriceModel::query()
|
||||||
->whereIn('product_id', array_keys($aggregated))
|
->whereIn('product_id', array_keys($aggregated))
|
||||||
->groupBy('product_id')
|
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||||
->selectRaw('product_id, MIN(price) as min_price')
|
->groupBy('product_id');
|
||||||
->pluck('min_price', 'product_id');
|
|
||||||
|
$minPrices = [];
|
||||||
|
foreach ($aggregated as $productId => $item) {
|
||||||
|
$product = $products->get($productId);
|
||||||
|
$minPrice = null;
|
||||||
|
foreach ($priceRows->get($productId, collect()) as $priceRow) {
|
||||||
|
$actual = ProductPriceModel::calcActualPrice(
|
||||||
|
(int) $priceRow->price_type,
|
||||||
|
$priceRow->price,
|
||||||
|
$priceRow->percent,
|
||||||
|
(float) ($product->cost_price ?? 0),
|
||||||
|
);
|
||||||
|
if ($minPrice === null || bccomp($actual, $minPrice, 2) < 0) {
|
||||||
|
$minPrice = $actual;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$minPrices[$productId] = $minPrice ?? '0';
|
||||||
|
}
|
||||||
|
|
||||||
// 组装明细行并按「分类 sort → 商品 sort」排序
|
// 组装明细行并按「分类 sort → 商品 sort」排序
|
||||||
$rows = [];
|
$rows = [];
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class ProductModelFactory extends Factory
|
|||||||
'shelf_life' => 0,
|
'shelf_life' => 0,
|
||||||
'stock' => 0,
|
'stock' => 0,
|
||||||
'status' => ProductModel::STATUS_ON,
|
'status' => ProductModel::STATUS_ON,
|
||||||
|
'cost_price' => 0,
|
||||||
'remark' => '',
|
'remark' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class ProductPriceModelFactory extends Factory
|
|||||||
'product_id' => 0,
|
'product_id' => 0,
|
||||||
'level_id' => 0,
|
'level_id' => 0,
|
||||||
'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''),
|
'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''),
|
||||||
|
'price_type' => ProductPriceModel::PRICE_TYPE_FIXED,
|
||||||
|
'percent' => 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ return new class extends Migration
|
|||||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
$table->integer('shelf_life')->default(0)->comment('保质期');
|
||||||
$table->integer('stock')->default(0)->comment('库存');
|
$table->integer('stock')->default(0)->comment('库存');
|
||||||
$table->integer('status')->default(1)->comment('状态(1上架 0下架)');
|
$table->integer('status')->default(1)->comment('状态(1上架 0下架)');
|
||||||
|
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价(元,成本百分比计价基数)');
|
||||||
$table->string('remark', 255)->default('')->comment('备注');
|
$table->string('remark', 255)->default('')->comment('备注');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
@@ -57,7 +58,9 @@ return new class extends Migration
|
|||||||
$table->increments('id')->comment('价格ID');
|
$table->increments('id')->comment('价格ID');
|
||||||
$table->integer('product_id')->comment('商品ID');
|
$table->integer('product_id')->comment('商品ID');
|
||||||
$table->integer('level_id')->comment('客户等级ID');
|
$table->integer('level_id')->comment('客户等级ID');
|
||||||
$table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价');
|
$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->timestamps();
|
||||||
$table->unique(['product_id', 'level_id'], 'product_price_product_level_unique');
|
$table->unique(['product_id', 'level_id'], 'product_price_product_level_unique');
|
||||||
$table->comment('商品等级价格表');
|
$table->comment('商品等级价格表');
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/** 造一个商品分类(后台创建商品必填) */
|
||||||
|
private function makeCategory(): ProductCategoryModel
|
||||||
|
{
|
||||||
|
return ProductCategoryModel::create([
|
||||||
|
'parent_id' => 0,
|
||||||
|
'name' => '测试分类',
|
||||||
|
'sort' => 1,
|
||||||
|
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 造门店 + 上架商品(成本价 + 指定计价类型的价格行) + 该店用户 */
|
||||||
|
private function makeStoreWithPercentProduct(float $cost = 10, float $percent = 30): array
|
||||||
|
{
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换算边界:成本为 0、上浮 0%、非法类型兜底 */
|
||||||
|
public function test_calc_actual_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'),
|
||||||
|
'非法计价类型按固定价兜底'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台商品列表:含成本价列 + 等级价格行的 actual_price */
|
||||||
|
public function test_admin_list_contains_cost_price_and_actual_price(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$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);
|
||||||
|
|
||||||
|
$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'], '后台列表等级价格应为实际价');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 小程序列表:返回换算后实际价,且成本价不泄漏 */
|
||||||
|
public function test_mini_list_returns_actual_price_without_cost_price(): void
|
||||||
|
{
|
||||||
|
[$store, $product, $user] = $this->makeStoreWithPercentProduct();
|
||||||
|
$this->actingAsMiniUser($user);
|
||||||
|
|
||||||
|
$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('13.00', (string) $row['price'], '小程序端应返回百分比换算后的实际价');
|
||||||
|
$this->assertArrayNotHasKey('cost_price', $row, '成本价为商业敏感数据,不得泄漏到小程序端');
|
||||||
|
$this->assertArrayNotHasKey('prices', $row, '价格行原始数据(含 percent)也不应下发给小程序');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台创建商品:成本价 + 百分比计价行落库 */
|
||||||
|
public function test_create_product_with_cost_and_percent_pricing(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$category = $this->makeCategory();
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/product/goods', [
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'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();
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$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],
|
||||||
|
],
|
||||||
|
])->assertOk()->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->assertSame('12.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(),
|
||||||
|
'固定价等级不受成本价变更影响,不应收到通知'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量调价-百分比行:存 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)→ 验证失败 */
|
||||||
|
public function test_batch_price_empty_row_rejected(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$product = ProductModel::factory()->create();
|
||||||
|
|
||||||
|
$this->putJson('/product/goods/batchPrice', [
|
||||||
|
'updates' => [
|
||||||
|
['product_id' => $product->id],
|
||||||
|
],
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '调价行缺少成本价或等级价格');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 价格矩阵:行含成本价与每等级的计价类型/百分比,等级格为实际价 */
|
||||||
|
public function test_price_matrix_includes_cost_and_percent_columns(): 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_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||||
|
'percent' => 30,
|
||||||
|
'price' => 13,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/product/goods/priceMatrix');
|
||||||
|
$response->assertOk()->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 小程序下单:百分比计价行按实际价重算金额 */
|
||||||
|
public function test_mini_order_uses_actual_price_for_percent_row(): void
|
||||||
|
{
|
||||||
|
[$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30);
|
||||||
|
$this->actingAsMiniUser($user);
|
||||||
|
|
||||||
|
$this->postJson('/mini/order', [
|
||||||
|
'items' => [['product_id' => $product->id, 'quantity' => 3]],
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('success', true)
|
||||||
|
->assertJsonPath('data.total_amount', '39.00');
|
||||||
|
|
||||||
|
$item = $store->orders()->latest('id')->first()->items->first();
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
[$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30);
|
||||||
|
$this->actingAsMiniUser($user);
|
||||||
|
|
||||||
|
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 2.5])
|
||||||
|
->assertOk()->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->getJson('/mini/cart')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.price', '13.00')
|
||||||
|
->assertJsonPath('data.items.0.amount', '32.50')
|
||||||
|
->assertJsonPath('data.total_amount', '32.50');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,4 +99,41 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
|||||||
|
|
||||||
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
|
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 估算单价取「最低实际价」:固定价与百分比计价混合时按换算后值比较 */
|
||||||
|
public function test_generate_uses_min_actual_price_across_types(): void
|
||||||
|
{
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$levelFixed = CustomerLevelModel::factory()->create();
|
||||||
|
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$product = ProductModel::factory()->create([
|
||||||
|
'status' => ProductModel::STATUS_ON,
|
||||||
|
'cost_price' => 10,
|
||||||
|
]);
|
||||||
|
// 百分比行:10 × (1+40%) = 14.00;固定价行 5.00 → 估算应取 5.00
|
||||||
|
ProductPriceModel::factory()->create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'level_id' => $level->id,
|
||||||
|
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||||
|
'percent' => 40,
|
||||||
|
'price' => 14.00,
|
||||||
|
]);
|
||||||
|
ProductPriceModel::factory()->create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'level_id' => $levelFixed->id,
|
||||||
|
'price' => 5.00,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||||
|
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 3]]])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$item = PurchaseOrderModel::first()->items->first();
|
||||||
|
$this->assertSame('5.00', (string) $item->price, '估算单价应取换算后的最低实际价');
|
||||||
|
$this->assertSame('15.00', (string) $item->amount, '3 × 5.00');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export interface PriceMatrixParams {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null) */
|
/**
|
||||||
|
* A2 价格矩阵:行=商品,列=启用等级,值=实际销售价(缺失 null);
|
||||||
|
* 行内含 cost_price 与 price_type_{level_id} / percent_{level_id}
|
||||||
|
*/
|
||||||
export async function getPriceMatrix(params?: PriceMatrixParams) {
|
export async function getPriceMatrix(params?: PriceMatrixParams) {
|
||||||
return createAxios<IPriceMatrix>({
|
return createAxios<IPriceMatrix>({
|
||||||
url: '/product/goods/priceMatrix',
|
url: '/product/goods/priceMatrix',
|
||||||
@@ -18,7 +21,9 @@ export async function getPriceMatrix(params?: PriceMatrixParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A2 批量调价(提交后给受影响门店生成价格变更通知) */
|
/**
|
||||||
|
* A2 批量调价:支持成本价 / 固定价 / 成本百分比三类更新行(提交后给受影响门店生成价格变更通知)
|
||||||
|
*/
|
||||||
export async function batchPrice(updates: IBatchPriceUpdate[]) {
|
export async function batchPrice(updates: IBatchPriceUpdate[]) {
|
||||||
return createAxios({
|
return createAxios({
|
||||||
url: '/product/goods/batchPrice',
|
url: '/product/goods/batchPrice',
|
||||||
|
|||||||
@@ -54,12 +54,13 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
const valueArray = Array.isArray(value) ? value : [value];
|
const valueArray = Array.isArray(value) ? value : [value];
|
||||||
// changeType='id' 时表单值为文件 id:拉取文件信息回显;
|
// changeType='id' 时表单值为文件 id:拉取文件信息回显;
|
||||||
if (changeType === 'id') {
|
if (changeType === 'id') {
|
||||||
const ids = valueArray as number[];
|
const ids = valueArray.map(i => Number(i));
|
||||||
|
const oldFileList = fileList.filter(i => ids.includes(Number(i.uid)))
|
||||||
|
|
||||||
const idSet = new Set(fileList.map(obj => Number(obj.uid)));
|
const idSet = new Set(oldFileList.map(obj => Number(obj.uid)));
|
||||||
|
|
||||||
// 过滤出不在 Set 中的数字
|
// 过滤出不在 Set 中的数字
|
||||||
const missing = ids.filter(num => !idSet.has(Number(num)));
|
const missing = ids.filter(num => !idSet.has(num));
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
const fetchers = missing.map((id) => getFileInfo(id));
|
const fetchers = missing.map((id) => getFileInfo(id));
|
||||||
@@ -68,8 +69,10 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
.map((res) => res.data.data)
|
.map((res) => res.data.data)
|
||||||
.filter(i => !!i)
|
.filter(i => !!i)
|
||||||
.map(fileToList);
|
.map(fileToList);
|
||||||
setFileList([...files, ...fileList]);
|
setFileList([...files, ...oldFileList]);
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
setFileList(oldFileList);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const newFileList: UploadFile[] = (valueArray as ISysFileInfo[]).map(fileToList);
|
const newFileList: UploadFile[] = (valueArray as ISysFileInfo[]).map(fileToList);
|
||||||
|
|||||||
+32
-5
@@ -2,12 +2,19 @@ import type IProductCategory from '@/domain/iProductCategory.ts';
|
|||||||
import type ISupplier from '@/domain/iSupplier.ts';
|
import type ISupplier from '@/domain/iSupplier.ts';
|
||||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||||
|
|
||||||
/** 商品等级价格行 */
|
/** 商品等级价格行(null = 该等级未设定价格,编辑时移除该行) */
|
||||||
export interface IProductPrice {
|
export interface IProductPrice {
|
||||||
id?: number;
|
id?: number;
|
||||||
product_id?: number;
|
product_id?: number;
|
||||||
level_id?: number;
|
level_id?: number;
|
||||||
price?: 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 };
|
level?: { id: number; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +45,8 @@ export default interface IProduct {
|
|||||||
stock?: number;
|
stock?: number;
|
||||||
/** 状态 */
|
/** 状态 */
|
||||||
status?: number;
|
status?: number;
|
||||||
|
/** 成本价(元,成本百分比计价的基数) */
|
||||||
|
cost_price?: string | number;
|
||||||
/** 描述 */
|
/** 描述 */
|
||||||
remark?: string;
|
remark?: string;
|
||||||
/** 分类关联数据 */
|
/** 分类关联数据 */
|
||||||
@@ -55,12 +64,16 @@ export const PRODUCT_STATUS_MAP: Record<number, { text: string; color: string }>
|
|||||||
1: { text: '上架', color: 'success' },
|
1: { text: '上架', color: 'success' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 价格矩阵行(price_{level_id} 动态列) */
|
/**
|
||||||
|
* 价格矩阵行:固定列 + price_{level_id}(实际价)/ price_type_{level_id} / percent_{level_id} 动态列
|
||||||
|
*/
|
||||||
export type IPriceMatrixRow = {
|
export type IPriceMatrixRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
spec?: string;
|
spec?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
/** 成本价(null = 未设置或已清除) */
|
||||||
|
cost_price?: string | number | null;
|
||||||
} & Record<string, string | number | null | undefined>;
|
} & Record<string, string | number | null | undefined>;
|
||||||
|
|
||||||
export interface IPriceMatrix {
|
export interface IPriceMatrix {
|
||||||
@@ -69,8 +82,22 @@ export interface IPriceMatrix {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量调价更新行(三类,可混合同一行):
|
||||||
|
* - 成本行 { product_id, cost_price }
|
||||||
|
* - 固定价行 { product_id, level_id, price_type: 0, price }
|
||||||
|
* - 百分比行 { product_id, level_id, price_type: 1, percent }(price 可选,等价固定价)
|
||||||
|
*/
|
||||||
export interface IBatchPriceUpdate {
|
export interface IBatchPriceUpdate {
|
||||||
product_id: number;
|
product_id: number;
|
||||||
level_id: number;
|
/** 成本价(成本行) */
|
||||||
price: number | string;
|
cost_price?: number;
|
||||||
|
/** 客户等级ID(等级价格行) */
|
||||||
|
level_id?: number;
|
||||||
|
/** 计价类型:0 固定价(默认) 1 成本百分比 */
|
||||||
|
price_type?: number;
|
||||||
|
/** 固定价 / 百分比行的等价固定价 */
|
||||||
|
price?: number;
|
||||||
|
/** 成本上浮百分点(price_type=1 时必填) */
|
||||||
|
percent?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+222
-38
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
message,
|
message,
|
||||||
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag, Tree,
|
Tag, Tree,
|
||||||
@@ -29,14 +30,27 @@ import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 等级价格表单
|
* 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框;
|
||||||
|
* 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100))
|
||||||
*/
|
*/
|
||||||
const LevelPriceFields: React.FC<{
|
const LevelPriceFields: React.FC<{
|
||||||
form: FormInstance;
|
form: FormInstance;
|
||||||
levels: ICustomerLevel[];
|
levels: ICustomerLevel[];
|
||||||
}> = ({ form, levels }) => {
|
}> = ({ form, levels }) => {
|
||||||
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
|
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
|
||||||
|
const costPrice = Number(Form.useWatch<string | number | undefined>('cost_price', form) ?? 0);
|
||||||
|
|
||||||
if (levels.length === 0) {
|
if (levels.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -46,30 +60,92 @@ const LevelPriceFields: React.FC<{
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const setLevelPrice = (levelId: number, price: number | null) => {
|
const updateRow = (levelId: number, patch: Partial<IProductPrice>) => {
|
||||||
const next = prices.filter((p) => p.level_id !== levelId);
|
const next = prices.filter((p) => p.level_id !== levelId);
|
||||||
if (price !== null) {
|
next.push({ ...patch, level_id: levelId });
|
||||||
next.push({ level_id: levelId, price });
|
|
||||||
}
|
|
||||||
form.setFieldValue('prices', next);
|
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 (
|
return (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{levels.map((level) => {
|
{levels.map((level) => {
|
||||||
if (level.id == null) return null;
|
if (level.id == null) return null;
|
||||||
const row = prices.find((p) => p.level_id === level.id);
|
const row = prices.find((p) => p.level_id === level.id);
|
||||||
|
const type = row?.price_type ?? PRICE_TYPE_FIXED;
|
||||||
return (
|
return (
|
||||||
<InputNumber
|
<Space key={level.id} wrap={false}>
|
||||||
min={0}
|
<Text style={{ width: 60, display: 'inline-block', textAlign: 'right' }}>{level.name}</Text>
|
||||||
precision={2}
|
<Select
|
||||||
prefix={<span style={{ color: '#666' }}>{level.name}</span>}
|
size="middle"
|
||||||
suffix={'¥'}
|
style={{ width: 110 }}
|
||||||
placeholder="未设定"
|
value={type}
|
||||||
value={(row?.price as number | null) ?? null}
|
options={PRICE_TYPE_OPTIONS}
|
||||||
onChange={(v) => setLevelPrice(level.id as number, v)}
|
onChange={(v) => onTypeChange(level.id as number, row, v as number)}
|
||||||
style={{ width: 240 }}
|
/>
|
||||||
/>
|
{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>
|
</Space>
|
||||||
@@ -100,8 +176,12 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
||||||
const [matrixKeyword, setMatrixKeyword] = useState('');
|
const [matrixKeyword, setMatrixKeyword] = useState('');
|
||||||
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
||||||
/** 跨页未保存的调价:`${productId}:${levelId}` → price(用 ref 避免异步闭包读到旧值) */
|
/** 跨页未保存的等级调价:`${productId}:${levelId}` → { price, price_type }(用 ref 避免异步闭包读到旧值) */
|
||||||
const matrixDirtyRef = useRef<Record<string, number | null>>({});
|
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(() => {
|
useEffect(() => {
|
||||||
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
||||||
@@ -124,14 +204,22 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
const rows = res.data.data?.rows ?? [];
|
const rows = res.data.data?.rows ?? [];
|
||||||
|
// 服务端原始成本价快照(供跨页百分比行保存时反算 percent)
|
||||||
|
rows.forEach((row) => {
|
||||||
|
serverCostRef.current[row.id] = Number(row.cost_price ?? 0);
|
||||||
|
});
|
||||||
// 叠加跨页未保存的修改,保证翻页后输入值不回退
|
// 叠加跨页未保存的修改,保证翻页后输入值不回退
|
||||||
const dirty = matrixDirtyRef.current;
|
|
||||||
const merged = rows.map((row) => {
|
const merged = rows.map((row) => {
|
||||||
const next = { ...row };
|
const next = { ...row };
|
||||||
Object.keys(dirty).forEach((key) => {
|
// 成本价修改
|
||||||
|
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(':');
|
const [pid, lid] = key.split(':');
|
||||||
if (String(row.id) === pid) {
|
if (String(row.id) === pid) {
|
||||||
next[`price_${lid}`] = dirty[key];
|
next[`price_${lid}`] = snap.price;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@@ -157,21 +245,72 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
value: number | null
|
value: number | null
|
||||||
) => {
|
) => {
|
||||||
setMatrixRows((prev) =>
|
setMatrixRows((prev) =>
|
||||||
prev.map((row) =>
|
prev.map((row) => {
|
||||||
row.id === productId ? { ...row, [`price_${levelId}`]: value } : 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 取服务端快照) */
|
||||||
|
const onMatrixCostChange = (productId: number, value: number | null) => {
|
||||||
|
costDirtyRef.current[productId] = value;
|
||||||
|
setMatrixRows((prev) =>
|
||||||
|
prev.map((row) => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
matrixDirtyRef.current[`${productId}:${levelId}`] = value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
|
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
|
||||||
const saveMatrix = async () => {
|
const saveMatrix = async () => {
|
||||||
const updates: IBatchPriceUpdate[] = [];
|
const updates: IBatchPriceUpdate[] = [];
|
||||||
Object.entries(matrixDirtyRef.current).forEach(([key, price]) => {
|
// 成本价行
|
||||||
if (price === null || price === undefined) return;
|
Object.entries(costDirtyRef.current).forEach(([pid, cost]) => {
|
||||||
const [pid, lid] = key.split(':');
|
if (cost === null || cost === undefined) return;
|
||||||
updates.push({ product_id: Number(pid), level_id: Number(lid), price });
|
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) {
|
if (updates.length === 0) {
|
||||||
message.info('没有需要保存的价格调整');
|
message.info('没有需要保存的价格调整');
|
||||||
return;
|
return;
|
||||||
@@ -181,6 +320,7 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
await batchPrice(updates);
|
await batchPrice(updates);
|
||||||
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
||||||
matrixDirtyRef.current = {};
|
matrixDirtyRef.current = {};
|
||||||
|
costDirtyRef.current = {};
|
||||||
await loadMatrix();
|
await loadMatrix();
|
||||||
} finally {
|
} finally {
|
||||||
setSaveLoading(false);
|
setSaveLoading(false);
|
||||||
@@ -203,21 +343,47 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...matrixLevels.map((level) => ({
|
{
|
||||||
title: level.name,
|
title: '成本价',
|
||||||
key: `price_${level.id}`,
|
key: 'cost_price',
|
||||||
width: 150,
|
fixed: 'left',
|
||||||
|
width: 130,
|
||||||
render: (_: unknown, row: IPriceMatrixRow) => (
|
render: (_: unknown, row: IPriceMatrixRow) => (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
size="small"
|
size="small"
|
||||||
min={0}
|
min={0}
|
||||||
precision={2}
|
precision={2}
|
||||||
prefix="¥"
|
prefix="¥"
|
||||||
value={row[`price_${level.id}`] as number | null}
|
value={row.cost_price as number | null}
|
||||||
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
|
onChange={(v) => onMatrixCostChange(row.id, v)}
|
||||||
className="w-32"
|
className="w-32"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
},
|
||||||
|
...matrixLevels.map((level) => ({
|
||||||
|
title: level.name,
|
||||||
|
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);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -349,20 +515,38 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
placeholder: '输入商品图文详情,支持插入图片',
|
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: '等级价格',
|
title: '等级价格',
|
||||||
dataIndex: 'prices',
|
dataIndex: 'prices',
|
||||||
hideInForm: true,
|
hideInForm: true,
|
||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
width: 370,
|
width: 420,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{record.prices?.length
|
{record.prices?.length
|
||||||
? record.prices.map((p) => (
|
? record.prices.map((p) => (
|
||||||
<Tag key={p.id} color="geekblue">
|
<Tag
|
||||||
|
key={p.id}
|
||||||
|
color="geekblue"
|
||||||
|
title={Number(p.price_type) === PRICE_TYPE_PERCENT ? `按成本价上浮 ${p.percent}%` : undefined}
|
||||||
|
>
|
||||||
{p.level?.name ?? `等级${p.level_id}`}
|
{p.level?.name ?? `等级${p.level_id}`}
|
||||||
<span style={{color: 'red', marginLeft: 5 }}>¥{p.price ?? '未设定'}</span>
|
<span style={{color: 'red', marginLeft: 5 }}>
|
||||||
|
¥{p.actual_price ?? p.price ?? '未设定'}
|
||||||
|
</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
))
|
))
|
||||||
: '-'}
|
: '-'}
|
||||||
@@ -533,7 +717,7 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
loadMatrix(matrixKeyword, matrixCategory, page, pageSize);
|
loadMatrix(matrixKeyword, matrixCategory, page, pageSize);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
scroll={{ x: matrixLevels.length * 150 + 160 }}
|
scroll={{ x: matrixLevels.length * 150 + 290 }}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user