价格增加百分比上浮

This commit is contained in:
liu
2026-08-06 14:52:56 +08:00
parent 07b08c8915
commit c6f69c5c55
19 changed files with 958 additions and 93 deletions
File diff suppressed because one or more lines are too long
+17 -6
View File
@@ -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;
+12 -4
View File
@@ -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 批量调价:事务写入,写完后给受影响门店生成 Noticetype=price * A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入,
* 写完后给受影响门店生成 Noticetype=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',
]; ];
} }
} }
+8
View File
@@ -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'];
/** /**
* 封面图片 * 封面图片
*/ */
+54
View File
@@ -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),
);
}
} }
+23 -6
View File
@@ -16,7 +16,7 @@ use Illuminate\Support\Facades\DB;
* 流程(事务内): * 流程(事务内):
* 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等) * 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等)
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商) * 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
* 3. 估算单价 = 该商品最低等级价(product_price MIN),amount = quantity × 估算单价 * 3. 估算单价 = 该商品最低实际等级价(按计价类型换算后取 minPHP 侧兼容 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('商品等级价格表');
+377
View File
@@ -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');
}
}
+37
View File
@@ -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');
}
} }
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 → costnull 视为未修改,不提交) */
const costDirtyRef = useRef<Record<number, number | null>>({});
/** 服务端原始成本价:productId → costloadMatrix 填充;跨页百分比行保存时反算 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>
</> </>