订单详情
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -83,12 +83,19 @@ class OrderController extends BaseMiniController
|
||||
$rows[] = [
|
||||
'store_id' => $store->id,
|
||||
'product_id' => $productId,
|
||||
'category_id' => (int) $product->category_id,
|
||||
'supplier_id' => (int) $product->supplier_id,
|
||||
'product_name' => $product->name,
|
||||
'product_spec' => $product->spec,
|
||||
'unit' => (string) $product->unit,
|
||||
'price' => $price,
|
||||
'image_ids' => implode(',', (array) $product->image_ids),
|
||||
'content' => (string) $product->content,
|
||||
'shelf_life' => (int) $product->shelf_life,
|
||||
'quantity' => $quantity,
|
||||
'weight' => 0,
|
||||
'amount' => $amount,
|
||||
'cost_price' => (string) $product->cost_price,
|
||||
'remark' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Order;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
@@ -15,9 +16,10 @@ use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 门店订单管理(订单只读 + 状态管理;创建/取消在小程序端)
|
||||
* 门店订单管理(订单只读 + 状态管理 + 明细修改/同步;创建/取消在小程序端)
|
||||
*/
|
||||
#[RequestAttribute('/order/store', 'order.store')]
|
||||
class StoreOrderController extends BaseController
|
||||
@@ -32,6 +34,14 @@ class StoreOrderController extends BaseController
|
||||
StoreOrderModel::STATUS_CANCELLED => '已取消',
|
||||
];
|
||||
|
||||
/** 明细可编辑状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
|
||||
private const array ITEM_EDITABLE_STATUS = [
|
||||
StoreOrderModel::STATUS_PENDING,
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
];
|
||||
|
||||
protected array $searchField = [
|
||||
'store_id' => '=',
|
||||
'status' => '=',
|
||||
@@ -39,7 +49,7 @@ class StoreOrderController extends BaseController
|
||||
'order_date' => 'betweenDate',
|
||||
];
|
||||
|
||||
/** 订单列表 */
|
||||
/** 订单列表(支持按包含的商品名称搜索 ?product_name=) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
@@ -47,14 +57,36 @@ class StoreOrderController extends BaseController
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$query = StoreOrderModel::query()->with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'items:id,order_id,product_id,product_name,product_spec,price,quantity,amount',
|
||||
'items.product'
|
||||
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
||||
]);
|
||||
|
||||
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
||||
$productName = trim((string) ($params['product_name'] ?? ''));
|
||||
if ($productName !== '') {
|
||||
$keyword = '%' . str_replace('%', '\%', $productName) . '%';
|
||||
$query->whereHas('items', static function ($itemQuery) use ($keyword) {
|
||||
$itemQuery->where('product_name', 'like', $keyword);
|
||||
});
|
||||
}
|
||||
|
||||
$data = $this->buildSearch($params, $query)
|
||||
->orderBy('order_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
|
||||
// Paginator::toArray() 的 data 仍为模型,先显式转数组(明细/关系一并转换)
|
||||
$data['data'] = array_map(
|
||||
static fn ($order) => is_object($order) ? $order->toArray() : $order,
|
||||
$data['data']
|
||||
);
|
||||
|
||||
// 明细封面图:从快照 image_ids 批量解析(不再关联商品档案表)
|
||||
foreach ($data['data'] as &$order) {
|
||||
$this->resolveItemImages($order['items']);
|
||||
}
|
||||
unset($order);
|
||||
|
||||
$box_amount = site_config('services.box_amount');
|
||||
$tray_amount = site_config('services.tray_amount');
|
||||
foreach ($data['data'] as &$item) {
|
||||
@@ -62,21 +94,13 @@ class StoreOrderController extends BaseController
|
||||
$item['tray_price'] = number_format($tray_amount, 2);
|
||||
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
|
||||
$item['tray_amount'] = number_format($tray_amount * $item['tray_num'], 2);
|
||||
if(count($item['items']) > 0) {
|
||||
foreach ($item['items'] as &$product) {
|
||||
if($product['product'] && $product['product']['images_arr'] > 0) {
|
||||
$product['image'] = $product['product']['images_arr'][0]['preview_url'] ?? '';
|
||||
unset($product['product']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已接单预览:聚合所有已接单订单明细(按商品分组),
|
||||
* 供生成采购单前确认(C1 前置)
|
||||
* 供生成采购单前确认(C1 前置);单位取明细快照
|
||||
*/
|
||||
#[GetRoute('/summary', 'query')]
|
||||
public function summary(): JsonResponse
|
||||
@@ -85,6 +109,7 @@ class StoreOrderController extends BaseController
|
||||
->select('product_id')
|
||||
->selectRaw('MAX(product_name) as product_name')
|
||||
->selectRaw('MAX(product_spec) as product_spec')
|
||||
->selectRaw('MAX(unit) as unit')
|
||||
->selectRaw('SUM(quantity) as total_quantity')
|
||||
->selectRaw('COUNT(DISTINCT store_id) as store_count')
|
||||
->whereHas('order', function ($query) {
|
||||
@@ -95,26 +120,205 @@ class StoreOrderController extends BaseController
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// 补充计价单位(商品档案,含已下架/软删除)
|
||||
$units = ProductModel::withTrashed()
|
||||
->whereIn('id', array_column($rows, 'product_id'))
|
||||
->pluck('unit', 'id');
|
||||
foreach ($rows as &$row) {
|
||||
$row['unit'] = $units[$row['product_id']] ?? '';
|
||||
// 快照无单位的历史明细兜底:从商品档案(含已下架/软删除)补齐
|
||||
$emptyUnitProductIds = array_column(array_filter(
|
||||
$rows,
|
||||
static fn (array $row) => ($row['unit'] ?? '') === ''
|
||||
), 'product_id');
|
||||
if ($emptyUnitProductIds !== []) {
|
||||
$units = ProductModel::withTrashed()
|
||||
->whereIn('id', $emptyUnitProductIds)
|
||||
->pluck('unit', 'id');
|
||||
foreach ($rows as &$row) {
|
||||
if (($row['unit'] ?? '') === '') {
|
||||
$row['unit'] = $units[$row['product_id']] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success($rows);
|
||||
}
|
||||
|
||||
/** 订单详情:订单头 + 明细(含商品快照) */
|
||||
/** 订单详情:订单头 + 明细(含商品快照、首图;后台侧成本价可见、附供应商名) */
|
||||
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id): JsonResponse
|
||||
{
|
||||
$order = StoreOrderModel::with(['store:id,name', 'items'])->find($id);
|
||||
$order = StoreOrderModel::with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'items' => static fn ($query) => $query->with('supplier:id,name'),
|
||||
])->find($id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
return $this->success($order->toArray());
|
||||
|
||||
// 成本价默认对序列化隐藏(防泄漏到小程序端),后台恢复可见
|
||||
$order->items->each->makeVisible('cost_price');
|
||||
|
||||
$data = $order->toArray();
|
||||
|
||||
// 明细首图 + 附加金额(与列表接口一致)
|
||||
$this->resolveItemImages($data['items']);
|
||||
$boxAmount = site_config('services.box_amount');
|
||||
$trayAmount = site_config('services.tray_amount');
|
||||
$data['box_price'] = number_format($boxAmount, 2);
|
||||
$data['tray_price'] = number_format($trayAmount, 2);
|
||||
$data['box_amount'] = number_format($boxAmount * $data['box_num'], 2);
|
||||
$data['tray_amount'] = number_format($trayAmount * $data['tray_num'], 2);
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细首图解析:从快照 image_ids 批量解析文件 URL,未找到时置空字符串(列表/详情共用)
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $items 订单明细数组(引用修改)
|
||||
*/
|
||||
private function resolveItemImages(array &$items): void
|
||||
{
|
||||
$fileIds = [];
|
||||
foreach ($items as $line) {
|
||||
foreach ((array) ($line['image_ids'] ?? []) as $fileId) {
|
||||
if ($fileId !== '' && $fileId !== null) {
|
||||
$fileIds[] = (int) $fileId;
|
||||
}
|
||||
}
|
||||
}
|
||||
$fileUrls = [];
|
||||
if ($fileIds !== []) {
|
||||
foreach (SysFileModel::query()->whereIn('id', array_unique($fileIds))->get() as $file) {
|
||||
$fileUrls[$file->id] = $file->preview_url;
|
||||
}
|
||||
}
|
||||
foreach ($items as &$product) {
|
||||
$product['image'] = '';
|
||||
foreach ((array) ($product['image_ids'] ?? []) as $fileId) {
|
||||
if ($fileId !== '' && $fileId !== null && isset($fileUrls[(int) $fileId])) {
|
||||
$product['image'] = $fileUrls[(int) $fileId];
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($product['image_ids']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改订单明细(商品快照 + 订货量/重量),事务内重算单品金额与订单总价。
|
||||
* 可改字段:供应商、品名、规格、单位、单价、成本价、订货量、重量
|
||||
*/
|
||||
#[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function updateItem(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'supplier_id' => 'required|integer|min:0',
|
||||
'product_name' => 'required|string|max:100',
|
||||
'product_spec' => 'nullable|string|max:100',
|
||||
'unit' => 'required|string|max:20',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'cost_price' => 'required|numeric|min:0',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
'weight' => 'required|numeric|min:0',
|
||||
'remark' => 'nullable|string'
|
||||
], [
|
||||
'supplier_id.required' => '供应商不能为空',
|
||||
'supplier_id.integer' => '供应商ID必须为整数',
|
||||
'supplier_id.min' => '供应商ID不正确',
|
||||
'product_name.required' => '品名不能为空',
|
||||
'product_name.max' => '品名最长 100 个字符',
|
||||
'product_spec.max' => '规格最长 100 个字符',
|
||||
'unit.required' => '计价单位不能为空',
|
||||
'unit.max' => '计价单位最长 20 个字符',
|
||||
'price.required' => '单价不能为空',
|
||||
'price.numeric' => '单价必须为数字',
|
||||
'price.min' => '单价不能小于 0',
|
||||
'cost_price.required' => '成本价不能为空',
|
||||
'cost_price.numeric' => '成本价必须为数字',
|
||||
'cost_price.min' => '成本价不能小于 0',
|
||||
'quantity.required' => '订货量不能为空',
|
||||
'quantity.integer' => '订货量必须为整数',
|
||||
'quantity.min' => '订货量必须大于 0',
|
||||
'weight.required' => '重量不能为空',
|
||||
'weight.numeric' => '重量必须为数字',
|
||||
'weight.min' => '重量不能小于 0',
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$item = StoreOrderItemModel::query()->lockForUpdate()->find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('订单明细不存在');
|
||||
}
|
||||
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
$this->assertItemEditable($order);
|
||||
|
||||
$item->fill($data);
|
||||
$item->product_spec = (string) ($data['product_spec'] ?? '');
|
||||
$item->amount = bcmul(
|
||||
bcadd((string) $data['price'], '0', 2),
|
||||
(string) $data['quantity'],
|
||||
2
|
||||
);
|
||||
$item->save();
|
||||
|
||||
$this->recalculateOrderTotals($order);
|
||||
|
||||
return $this->success($item->load('supplier:id,name')->makeVisible('cost_price')->toArray());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键同步明细商品快照:按商品ID同步最新商品档案的
|
||||
* 供应商、品名、规格、单位、成本价;单价按门店当前等级价重算
|
||||
* (未设置等级价时保留原单价),并重算订单总价
|
||||
*/
|
||||
#[PutRoute(route: '/item/{id}/sync', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function syncItem(int $id): JsonResponse
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$item = StoreOrderItemModel::query()->lockForUpdate()->find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('订单明细不存在');
|
||||
}
|
||||
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
$this->assertItemEditable($order);
|
||||
|
||||
// 软删除商品无法同步(下架商品仍可同步最新档案)
|
||||
$product = ProductModel::find($item->product_id);
|
||||
if (empty($product)) {
|
||||
throw new RepositoryException('商品不存在或已被删除,无法同步');
|
||||
}
|
||||
|
||||
$item->supplier_id = (int) $product->supplier_id;
|
||||
$item->product_name = $product->name;
|
||||
$item->product_spec = (string) $product->spec;
|
||||
$item->unit = (string) $product->unit;
|
||||
$item->cost_price = $product->cost_price;
|
||||
|
||||
// 单价:按订货门店当前客户等级价重算(百分比计价按最新成本价换算)
|
||||
$levelId = (int) ($order->store->level_id ?? 0);
|
||||
$priceRow = ProductPriceModel::query()
|
||||
->forProductLevel((int) $item->product_id, $levelId)
|
||||
->first(['price', 'price_type', 'percent']);
|
||||
if ($priceRow !== null) {
|
||||
$item->price = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
$product->cost_price,
|
||||
);
|
||||
}
|
||||
|
||||
$item->amount = bcmul((string) $item->price, (string) $item->quantity, 2);
|
||||
$item->save();
|
||||
|
||||
$this->recalculateOrderTotals($order);
|
||||
|
||||
return $this->success($item->load('supplier:id,name')->makeVisible('cost_price')->toArray());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +478,39 @@ class StoreOrderController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细编辑状态校验:已完成/已取消订单锁定
|
||||
*/
|
||||
private function assertItemEditable(StoreOrderModel $order): void
|
||||
{
|
||||
if (! in_array($order->status, self::ITEM_EDITABLE_STATUS, true)) {
|
||||
throw new RepositoryException(
|
||||
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改商品明细'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算订单汇总:明细金额合计 → 商品总金额;订货量/重量合计 → 订货总量/总重量;
|
||||
* 订单总金额 = 商品总金额 + 附加金额(恒成立)
|
||||
*/
|
||||
private function recalculateOrderTotals(StoreOrderModel $order): void
|
||||
{
|
||||
$totals = StoreOrderItemModel::query()
|
||||
->where('order_id', $order->id)
|
||||
->selectRaw('COALESCE(SUM(quantity), 0) as total_quantity')
|
||||
->selectRaw('COALESCE(SUM(weight), 0) as total_weight')
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as product_amount')
|
||||
->first();
|
||||
|
||||
$productAmount = bcadd((string) $totals->product_amount, '0', 2);
|
||||
$order->total_quantity = (int) $totals->total_quantity;
|
||||
$order->total_weight = bcadd((string) $totals->total_weight, '0', 3);
|
||||
$order->product_amount = $productAmount;
|
||||
$order->total_amount = bcadd($productAmount, (string) $order->added_amount, 2);
|
||||
$order->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态流转后通知门店用户
|
||||
*/
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 门店订单明细模型(快照下单时的商品名/规格/等级价,历史单据不受调价影响)
|
||||
* 门店订单明细模型(下单时快照商品档案:品名/规格/单位/供应商/图文/成本价 + 等级单价,
|
||||
* 商品调价或档案变更不影响历史单据)
|
||||
*/
|
||||
class StoreOrderItemModel extends Model
|
||||
{
|
||||
@@ -20,12 +22,19 @@ class StoreOrderItemModel extends Model
|
||||
'order_id',
|
||||
'store_id',
|
||||
'product_id',
|
||||
'category_id',
|
||||
'supplier_id',
|
||||
'product_name',
|
||||
'product_spec',
|
||||
'unit',
|
||||
'price',
|
||||
'image_ids',
|
||||
'content',
|
||||
'shelf_life',
|
||||
'quantity',
|
||||
'weight',
|
||||
'amount',
|
||||
'cost_price',
|
||||
'remark',
|
||||
];
|
||||
|
||||
@@ -33,12 +42,33 @@ class StoreOrderItemModel extends Model
|
||||
'order_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'category_id' => 'integer',
|
||||
'supplier_id' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'shelf_life' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
'weight' => 'decimal:3',
|
||||
'amount' => 'decimal:2',
|
||||
'cost_price' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 成本价属商业敏感数据,默认不随 toArray 输出(防止泄漏到小程序端);
|
||||
* 后台管理接口需在查询结果上调用 makeVisible('cost_price') 恢复。
|
||||
*/
|
||||
protected $hidden = ['cost_price'];
|
||||
|
||||
/**
|
||||
* 商品图片ID(逗号分隔字符串 ↔ 数组)
|
||||
*/
|
||||
public function imageIds(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn ($value) => $value === '' || $value === null ? [] : explode(',', (string) $value),
|
||||
set: fn ($value) => is_array($value) ? implode(',', $value) : $value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属订单
|
||||
*/
|
||||
@@ -62,4 +92,20 @@ class StoreOrderItemModel extends Model
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照供应商
|
||||
*/
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照商品分类
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategoryModel::class, 'category_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,19 @@ class StoreOrderItemModelFactory extends Factory
|
||||
'order_id' => 0,
|
||||
'store_id' => 0,
|
||||
'product_id' => 0,
|
||||
'category_id' => 0,
|
||||
'supplier_id' => 0,
|
||||
'product_name' => '测试商品',
|
||||
'product_spec' => '500g/袋',
|
||||
'unit' => '斤',
|
||||
'price' => number_format(random_int(100, 5000) / 100, 2, '.', ''),
|
||||
'image_ids' => '',
|
||||
'content' => '',
|
||||
'shelf_life' => 0,
|
||||
'quantity' => number_format(random_int(100, 10000) / 100, 2, '.', ''),
|
||||
'weight' => 0,
|
||||
'amount' => 0,
|
||||
'cost_price' => 0,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,14 +42,21 @@ return new class extends Migration
|
||||
Schema::create('store_order_item', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('明细ID');
|
||||
$table->integer('order_id')->comment('订单ID');
|
||||
$table->integer('store_id')->comment('门店ID(冗余,便于按门店筛选)');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->string('product_name', 100)->comment('品名(快照)');
|
||||
$table->string('product_spec', 100)->default('')->comment('规格/包规(快照)');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('单价(下单时客户等级价快照)');
|
||||
$table->integer('category_id')->default(0)->comment('分类ID');
|
||||
$table->integer('supplier_id')->default(0)->comment('供应商ID');
|
||||
$table->string('product_name', 100)->comment('品名');
|
||||
$table->string('product_spec', 100)->default('')->comment('规格/包规');
|
||||
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('单价');
|
||||
$table->string('image_ids', 255)->default('')->comment('商品图片');
|
||||
$table->text('content')->comment('商品图文详情');
|
||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
||||
$table->integer('quantity')->default(0)->comment('订货量');
|
||||
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
|
||||
$table->decimal('amount', 10, 2)->default(0)->comment('单品金额');
|
||||
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价');
|
||||
$table->string('remark', 255)->default('')->comment('门店下单备注');
|
||||
$table->timestamps();
|
||||
$table->index(['order_id'], 'store_order_item_order_index');
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 门店订单明细(商品快照):
|
||||
* 下单快照完整商品档案、成本价防泄漏、后台明细修改重算总价、一键同步商品档案、按商品名称搜索订单
|
||||
*/
|
||||
class StoreOrderItemTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造门店 + 上架商品(指定成本价)+ 固定等级价 + 门店用户
|
||||
*
|
||||
* @return array{0: StoreModel, 1: ProductModel, 2: UserModel}
|
||||
*/
|
||||
private function makeStoreWithProduct(string $price = '5.00', string $costPrice = '4.00'): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $costPrice,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
|
||||
/** 以小程序身份下一单并返回订单(可指定初始状态) */
|
||||
private function placeOrder(ProductModel $product, UserModel $user, int $quantity = 2, int $status = StoreOrderModel::STATUS_PENDING): StoreOrderModel
|
||||
{
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $quantity]]])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order = StoreOrderModel::where('store_id', $user->store_id)->latest('id')->first();
|
||||
$this->assertNotNull($order);
|
||||
if ($status !== StoreOrderModel::STATUS_PENDING) {
|
||||
$order->update(['status' => $status]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** 下单时明细快照完整商品档案(分类/供应商/单位/图片/图文/保质期/成本价) */
|
||||
public function test_place_order_snapshots_full_product_info(): void
|
||||
{
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'supplier_id' => $supplier->id,
|
||||
'category_id' => 7,
|
||||
'unit' => '箱',
|
||||
'image_ids' => '1,2',
|
||||
'content' => '图文详情',
|
||||
'shelf_life' => 30,
|
||||
'cost_price' => '4.00',
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.50',
|
||||
]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 3]]])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item = StoreOrderModel::where('store_id', $store->id)->first()->items->first();
|
||||
$this->assertSame($supplier->id, $item->supplier_id, '快照供应商');
|
||||
$this->assertSame(7, $item->category_id, '快照分类');
|
||||
$this->assertSame('箱', $item->unit, '快照单位');
|
||||
$this->assertSame('1,2', implode(',', $item->image_ids), '快照图片');
|
||||
$this->assertSame('图文详情', $item->content, '快照图文详情');
|
||||
$this->assertSame(30, $item->shelf_life, '快照保质期');
|
||||
$this->assertSame('4.00', (string) $item->cost_price, '快照成本价');
|
||||
$this->assertSame('5.50', (string) $item->price, '快照等级单价');
|
||||
}
|
||||
|
||||
/** 小程序订单详情不泄漏成本价 */
|
||||
public function test_mini_detail_hides_cost_price(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00', '4.00');
|
||||
$order = $this->placeOrder($product, $user);
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->getJson("/mini/order/{$order->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonMissingPath('data.items.0.cost_price');
|
||||
}
|
||||
|
||||
/** 后台订单详情:成本价可见、附供应商名与单位 */
|
||||
public function test_admin_detail_shows_cost_price_and_supplier(): void
|
||||
{
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00', '4.00');
|
||||
$product->update(['supplier_id' => $supplier->id]);
|
||||
$order = $this->placeOrder($product, $user);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->getJson("/order/store/{$order->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.items.0.cost_price', '4.00')
|
||||
->assertJsonPath('data.items.0.unit', $product->unit)
|
||||
->assertJsonPath('data.items.0.supplier.name', $supplier->name);
|
||||
}
|
||||
|
||||
/** 修改明细:重算单品金额与订单总量/总额(总额 = 商品 + 附加) */
|
||||
public function test_update_item_recalculates_order_totals(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$order->update(['added_amount' => '6.00']); // 已有附加金额
|
||||
$item = $order->items->first();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}", [
|
||||
'supplier_id' => $supplier->id,
|
||||
'product_name' => '改名后的商品',
|
||||
'product_spec' => '新规格',
|
||||
'unit' => '箱',
|
||||
'price' => '6.00',
|
||||
'cost_price' => '3.50',
|
||||
'quantity' => 5,
|
||||
'weight' => '2.5',
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item->refresh();
|
||||
$this->assertSame($supplier->id, $item->supplier_id);
|
||||
$this->assertSame('改名后的商品', $item->product_name);
|
||||
$this->assertSame('新规格', $item->product_spec);
|
||||
$this->assertSame('箱', $item->unit);
|
||||
$this->assertSame('6.00', (string) $item->price);
|
||||
$this->assertSame('3.50', (string) $item->cost_price);
|
||||
$this->assertSame(5, $item->quantity);
|
||||
$this->assertSame('2.500', (string) $item->weight);
|
||||
$this->assertSame('30.00', (string) $item->amount, '6.00 × 5');
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计');
|
||||
$this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计');
|
||||
$this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计');
|
||||
$this->assertSame('36.00', (string) $order->total_amount, '订单总金额 = 商品 30 + 附加 6');
|
||||
}
|
||||
|
||||
/** 已完成/已取消订单不允许修改明细 */
|
||||
public function test_update_item_rejected_when_completed_or_cancelled(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 1);
|
||||
$item = $order->items->first();
|
||||
$payload = [
|
||||
'supplier_id' => 0,
|
||||
'product_name' => '不应生效',
|
||||
'product_spec' => '',
|
||||
'unit' => '斤',
|
||||
'price' => '1.00',
|
||||
'cost_price' => '1.00',
|
||||
'quantity' => 1,
|
||||
'weight' => 0,
|
||||
];
|
||||
|
||||
$this->actingAsSysUser();
|
||||
foreach ([StoreOrderModel::STATUS_COMPLETED, StoreOrderModel::STATUS_CANCELLED] as $status) {
|
||||
$order->update(['status' => $status]);
|
||||
$this->putJson("/order/store/item/{$item->id}", $payload)
|
||||
->assertOk()->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
$this->assertNotSame('不应生效', $item->fresh()->product_name, '被拒绝后明细不变');
|
||||
$this->assertSame('5.00', (string) $order->fresh()->total_amount, '被拒绝后总金额不变');
|
||||
}
|
||||
|
||||
/** 修改明细参数校验:负单价被拦截 */
|
||||
public function test_update_item_validates_negative_price(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}", [
|
||||
'supplier_id' => 0,
|
||||
'product_name' => $item->product_name,
|
||||
'unit' => '斤',
|
||||
'price' => -1,
|
||||
'cost_price' => 0,
|
||||
'quantity' => 1,
|
||||
'weight' => 0,
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '单价不能小于 0');
|
||||
}
|
||||
|
||||
/** 一键同步:按商品ID同步最新档案(固定价等级保留原单价) */
|
||||
public function test_sync_item_updates_snapshot_from_product(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.50', '4.00');
|
||||
$order = $this->placeOrder($product, $user, 3, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
$this->assertSame('16.50', (string) $item->amount);
|
||||
|
||||
// 下单后商品档案变更:改名/改规格/改单位/换供应商/调成本价
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$product->update([
|
||||
'name' => '同步后的品名',
|
||||
'spec' => '同步后的规格',
|
||||
'unit' => '箱',
|
||||
'supplier_id' => $supplier->id,
|
||||
'cost_price' => '9.99',
|
||||
]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}/sync")
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item->refresh();
|
||||
$this->assertSame('同步后的品名', $item->product_name);
|
||||
$this->assertSame('同步后的规格', $item->product_spec);
|
||||
$this->assertSame('箱', $item->unit);
|
||||
$this->assertSame($supplier->id, $item->supplier_id);
|
||||
$this->assertSame('9.99', (string) $item->cost_price);
|
||||
$this->assertSame('5.50', (string) $item->price, '固定价等级单价不随档案变化');
|
||||
$this->assertSame('16.50', (string) $item->amount, '单价未变,金额不变');
|
||||
}
|
||||
|
||||
/** 一键同步:百分比计价等级按最新成本价重算单价与订单总价 */
|
||||
public function test_sync_item_recalculates_percent_price_with_latest_cost(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => '10.00',
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
$this->assertSame('13.00', (string) $item->price, '下单时 10 元上浮 30%');
|
||||
$this->assertSame('26.00', (string) $order->total_amount);
|
||||
|
||||
// 成本价上调后同步:单价应按最新成本重算
|
||||
$product->update(['cost_price' => '20.00']);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}/sync")
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item->refresh();
|
||||
$this->assertSame('20.00', (string) $item->cost_price);
|
||||
$this->assertSame('26.00', (string) $item->price, '20 元上浮 30% = 26.00');
|
||||
$this->assertSame('52.00', (string) $item->amount, '26.00 × 2');
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame('52.00', (string) $order->product_amount);
|
||||
$this->assertSame('52.00', (string) $order->total_amount);
|
||||
}
|
||||
|
||||
/** 一键同步:商品已删除时拒绝同步 */
|
||||
public function test_sync_item_rejected_when_product_deleted(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
|
||||
$product->delete(); // 软删除
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}/sync")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '商品不存在或已被删除,无法同步');
|
||||
}
|
||||
|
||||
/** 订单列表按包含的商品名称搜索 */
|
||||
public function test_order_list_search_by_product_name(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$cabbage = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '大白菜A']);
|
||||
$potato = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '土豆B']);
|
||||
foreach ([$cabbage, $potato] as $p) {
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $p->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.00',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->placeOrder($cabbage, $user);
|
||||
$this->placeOrder($potato, $user);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$response = $this->getJson('/order/store?product_name=' . urlencode('白菜'));
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
$this->assertSame(1, $response->json('data.total'), '仅命中包含「白菜」的订单');
|
||||
$this->assertSame('大白菜A', $response->json('data.data.0.items.0.product_name'));
|
||||
|
||||
// 无匹配关键字 → 空列表
|
||||
$this->getJson('/order/store?product_name=' . urlencode('不存在的商品'))
|
||||
->assertOk()->assertJsonPath('data.total', 0);
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -1,6 +1,6 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type IStoreOrder from '@/domain/iStoreOrder.ts';
|
||||
import type { IOrderSummaryRow } from '@/domain/iStoreOrder.ts';
|
||||
import type { IOrderSummaryRow, IStoreOrderItem, IStoreOrderItemUpdate } from '@/domain/iStoreOrder.ts';
|
||||
|
||||
/** 订单详情(头 + 明细) */
|
||||
export async function getStoreOrder(id: number) {
|
||||
@@ -44,3 +44,20 @@ export async function getOrderSummary() {
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改订单明细(供应商/品名/规格/单位/单价/成本价/订货量/重量,后端重算订单总价) */
|
||||
export async function updateOrderItem(itemId: number, data: IStoreOrderItemUpdate) {
|
||||
return createAxios<IStoreOrderItem>({
|
||||
url: `/order/store/item/${itemId}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 一键同步明细商品快照为最新商品档案信息(供应商/品名/规格/单位/单价/成本价) */
|
||||
export async function syncOrderItem(itemId: number) {
|
||||
return createAxios<IStoreOrderItem>({
|
||||
url: `/order/store/item/${itemId}/sync`,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
/** 门店订单明细(商品快照) */
|
||||
/** 门店订单明细(下单时商品档案快照) */
|
||||
export interface IStoreOrderItem {
|
||||
id?: number;
|
||||
order_id?: number;
|
||||
store_id?: number;
|
||||
product_id?: number;
|
||||
category_id?: number;
|
||||
supplier_id?: number;
|
||||
/** 详情接口附带的供应商信息 */
|
||||
supplier?: { id: number; name: string };
|
||||
product_name?: string;
|
||||
product_spec?: string;
|
||||
/** 计价单位(斤/件/箱等) */
|
||||
unit?: string;
|
||||
price?: string;
|
||||
/** 列表接口附带的封面图 */
|
||||
image?: string;
|
||||
image_ids?: string;
|
||||
shelf_life?: number;
|
||||
quantity?: number;
|
||||
weight?: string;
|
||||
amount?: string;
|
||||
/** 成本价(仅后台接口可见) */
|
||||
cost_price?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** 修改订单明细入参(保存后后端重算单品金额与订单总价) */
|
||||
export interface IStoreOrderItemUpdate {
|
||||
supplier_id: number;
|
||||
product_name: string;
|
||||
product_spec?: string;
|
||||
unit: string;
|
||||
price: number;
|
||||
cost_price: number;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/** 门店订单 */
|
||||
export default interface IStoreOrder {
|
||||
id?: number;
|
||||
|
||||
+292
-40
@@ -4,20 +4,21 @@ import {
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Image,
|
||||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
@@ -25,20 +26,33 @@ import type {
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IStoreOrder from '@/domain/iStoreOrder.ts';
|
||||
import type { IStoreOrderItem } from '@/domain/iStoreOrder.ts';
|
||||
import type { IStoreOrderItem, IStoreOrderItemUpdate } from '@/domain/iStoreOrder.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import { getStoreOrder, updateOrderContainer, updateOrderStatus, batchUpdateOrderStatus } from '@/api/order/store.ts';
|
||||
import {
|
||||
getStoreOrder,
|
||||
updateOrderContainer,
|
||||
updateOrderStatus,
|
||||
batchUpdateOrderStatus,
|
||||
updateOrderItem,
|
||||
syncOrderItem,
|
||||
} from '@/api/order/store.ts';
|
||||
import { generatePurchase } from '@/api/purchase/order.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
import {UnorderedListOutlined} from "@ant-design/icons";
|
||||
import { EditOutlined, SyncOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
|
||||
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
|
||||
|
||||
/** 允许修改/同步商品明细的订单状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
|
||||
const ITEM_EDITABLE_STATUS = [0, 1, 2, 3];
|
||||
|
||||
/**
|
||||
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
|
||||
* (已接单→采购中 通过「生成采购单」完成,不在流转按钮内)
|
||||
@@ -90,8 +104,18 @@ const StoreOrderPage: React.FC = () => {
|
||||
const [generateLoading, setGenerateLoading] = useState(false);
|
||||
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
|
||||
|
||||
/** 供应商选项(明细编辑用) */
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
/** 商品明细编辑(弹窗表单,保存后后端重算订单总价) */
|
||||
const [itemEditOpen, setItemEditOpen] = useState(false);
|
||||
const [itemEditTarget, setItemEditTarget] = useState<IStoreOrderItem | null>(null);
|
||||
const [itemSaving, setItemSaving] = useState(false);
|
||||
const [itemForm] = Form.useForm<IStoreOrderItemUpdate>();
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
@@ -165,6 +189,46 @@ const StoreOrderPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开明细编辑弹窗(商品快照 + 订货量/重量) */
|
||||
const openItemEdit = (item: IStoreOrderItem) => {
|
||||
setItemEditTarget(item);
|
||||
itemForm.setFieldsValue({
|
||||
supplier_id: item.supplier_id ?? 0,
|
||||
product_name: item.product_name ?? '',
|
||||
product_spec: item.product_spec ?? '',
|
||||
unit: item.unit ?? '',
|
||||
price: Number(item.price ?? 0),
|
||||
cost_price: Number(item.cost_price ?? 0),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
weight: Number(item.weight ?? 0),
|
||||
});
|
||||
setItemEditOpen(true);
|
||||
};
|
||||
|
||||
/** 保存明细修改:后端重算单品金额与订单总价 */
|
||||
const handleItemSave = async (values: IStoreOrderItemUpdate) => {
|
||||
if (!itemEditTarget?.id || !detail?.id) return;
|
||||
setItemSaving(true);
|
||||
try {
|
||||
await updateOrderItem(itemEditTarget.id, values);
|
||||
message.success('明细已更新,订单总价已重算');
|
||||
setItemEditOpen(false);
|
||||
await openDetail(detail.id);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setItemSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 一键同步:按商品ID拉取最新商品档案(供应商/品名/规格/单位/单价/成本价) */
|
||||
const handleItemSync = async (item: IStoreOrderItem) => {
|
||||
if (!item.id || !detail?.id) return;
|
||||
await syncOrderItem(item.id);
|
||||
message.success('已同步最新商品信息,订单总价已重算');
|
||||
await openDetail(detail.id);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
// 弹窗内实时预览:附加金额 = 框×单价 + 托盘×单价;订单总金额 = 商品金额 + 附加金额
|
||||
const watchBoxNum = Number(Form.useWatch('box_num', containerForm) ?? 0);
|
||||
const watchTrayNum = Number(Form.useWatch('tray_num', containerForm) ?? 0);
|
||||
@@ -172,14 +236,8 @@ const StoreOrderPage: React.FC = () => {
|
||||
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
|
||||
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
|
||||
|
||||
const itemColumns: TableProps<IStoreOrderItem>['columns'] = [
|
||||
{ title: '品名', dataIndex: 'product_name' },
|
||||
{ title: '规格', dataIndex: 'product_spec', render: (v) => v || '-' },
|
||||
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` },
|
||||
{ title: '订货量', dataIndex: 'quantity', align: 'right' },
|
||||
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` },
|
||||
{ title: '备注', dataIndex: 'remark', render: (v) => v || '-' },
|
||||
];
|
||||
/** 明细行编辑/同步按钮(仅未完成、未取消订单可见) */
|
||||
const itemEditable = ITEM_EDITABLE_STATUS.includes(detail?.status ?? -1);
|
||||
|
||||
const columns: XinTableColumn<IStoreOrder>[] = [
|
||||
{
|
||||
@@ -189,6 +247,13 @@ const StoreOrderPage: React.FC = () => {
|
||||
valueType: 'text',
|
||||
hideInForm: true
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
hideInTable: true,
|
||||
dataIndex: 'product_name',
|
||||
valueType: 'text',
|
||||
hideInForm: true
|
||||
},
|
||||
{
|
||||
title: '商品信息',
|
||||
dataIndex: 'items',
|
||||
@@ -352,7 +417,7 @@ const StoreOrderPage: React.FC = () => {
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Space orientation={'vertical'}>
|
||||
<Button key="detail" type={'primary'} icon={<UnorderedListOutlined />} onClick={() => openDetail(record.id!)}>
|
||||
<Button key="detail" type={'link'} icon={<UnorderedListOutlined />} onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>
|
||||
{
|
||||
@@ -362,7 +427,7 @@ const StoreOrderPage: React.FC = () => {
|
||||
title={`确认将订单状态更新为「${action.label}」?`}
|
||||
onConfirm={() => handleStatusChange(record.id!, action.status)}
|
||||
>
|
||||
<Button type={action.danger ? undefined : 'primary'} danger={action.danger}>
|
||||
<Button type={action.danger ? undefined : 'link'} danger={action.danger}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -372,7 +437,7 @@ const StoreOrderPage: React.FC = () => {
|
||||
{
|
||||
CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) ? (
|
||||
<AuthButton key="container" auth="order.store.update">
|
||||
<Button type={'primary'} onClick={() => openContainer(record)}>
|
||||
<Button type={'link'} onClick={() => openContainer(record)}>
|
||||
设置附加信息
|
||||
</Button>
|
||||
</AuthButton>
|
||||
@@ -429,7 +494,7 @@ const StoreOrderPage: React.FC = () => {
|
||||
title="订单详情"
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={720}
|
||||
size={1000}
|
||||
loading={detailLoading}
|
||||
footer={
|
||||
detail && NEXT_STATUS[detail.status ?? -1] ? (
|
||||
@@ -452,9 +517,21 @@ const StoreOrderPage: React.FC = () => {
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
{/* 门店信息 */}
|
||||
<Descriptions title="门店信息" column={3} size="small" bordered>
|
||||
<Descriptions.Item label="门店名称">
|
||||
{detail.store?.name ?? `门店#${detail.store_id}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="联系人">{detail.store?.contact ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">{detail.store?.phone ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址" span={3}>
|
||||
{detail.store?.address ?? '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<Descriptions title="订单信息" column={3} size="small" bordered className="mt-4!">
|
||||
<Descriptions.Item label="订单号">{detail.order_no}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="订货日期">{detail.order_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
@@ -462,34 +539,128 @@ const StoreOrderPage: React.FC = () => {
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订货总量">{detail.total_quantity}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单金额">¥{detail.total_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="周转框数量">{detail.box_num}</Descriptions.Item>
|
||||
<Descriptions.Item label="周转托盘数量">{detail.tray_num}</Descriptions.Item>
|
||||
{detail.remark ? (
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
<Descriptions.Item label="备注" span={3}>
|
||||
{detail.remark}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Title level={5} className="!mt-6 !mb-3">
|
||||
|
||||
{/* 商品明细(商城模式:首图 + 单价 × 订货量 + 金额) */}
|
||||
<Title level={5} className="mt-6! mb-3!">
|
||||
商品明细
|
||||
</Title>
|
||||
<Table<IStoreOrderItem>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items ?? []}
|
||||
pagination={false}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4} align="right">
|
||||
合计
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text strong>¥{detail.total_amount}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} />
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
<div className="overflow-hidden rounded border border-gray-200">
|
||||
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
|
||||
<div className="flex-1">商品信息</div>
|
||||
<div className="w-30 shrink-0 text-center">供应商</div>
|
||||
<div className="w-30 shrink-0 text-center">单价</div>
|
||||
<div className="w-26 shrink-0 text-center">订货量</div>
|
||||
<div className="w-33 shrink-0 text-center">金额</div>
|
||||
{itemEditable ? <div className="w-40 shrink-0 text-center">操作</div> : null}
|
||||
</div>
|
||||
{(detail.items ?? []).map((item) => (
|
||||
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Image.PreviewGroup>
|
||||
{item.image ? (
|
||||
<Image
|
||||
src={item.image}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
|
||||
暂无图片
|
||||
</div>
|
||||
)}
|
||||
</Image.PreviewGroup>
|
||||
<div className="ml-3 min-w-0">
|
||||
<div className="text-sm font-medium">{item.product_name}</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500">
|
||||
{item.product_spec || '-'} / {item.unit || '-'} · 成本价 ¥
|
||||
{item.cost_price ?? '0.00'}
|
||||
{Number(item.weight ?? 0) > 0 ? ` · 重量:${item.weight}` : ''}
|
||||
</div>
|
||||
{item.remark ? (
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">备注:{item.remark}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-30 shrink-0 text-center">{item.supplier?.name ?? '-'}</div>
|
||||
<div className="w-30 shrink-0 text-center">¥{item.price}</div>
|
||||
<div className="w-26 shrink-0 text-center">{item.quantity}</div>
|
||||
<div className="w-33 shrink-0 text-center">
|
||||
<Text strong>¥{item.amount}</Text>
|
||||
</div>
|
||||
{itemEditable ? (
|
||||
<div className="w-40 shrink-0 text-center">
|
||||
<Space size={0}>
|
||||
<AuthButton auth="order.store.update">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openItemEdit(item)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
title="确认同步最新商品信息?"
|
||||
description="将按商品ID同步供应商、品名、规格、单位、成本价,并按门店等级价重算单价"
|
||||
onConfirm={() => handleItemSync(item)}
|
||||
>
|
||||
<Button type="link" size="small" icon={<SyncOutlined />}>
|
||||
同步
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{(detail.items ?? []).length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无商品明细"
|
||||
className="py-8!"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 附加信息 */}
|
||||
<div className="mt-3! flex justify-end">
|
||||
<div className="w-full rounded bg-gray-50 p-4 text-sm">
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">
|
||||
周转框金额({detail.box_num} × ¥{detail.box_price ?? '0.00'})
|
||||
</Text>
|
||||
<span>¥{detail.box_amount ?? '0.00'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">
|
||||
周转托盘金额({detail.tray_num} × ¥{detail.tray_price ?? '0.00'})
|
||||
</Text>
|
||||
<span>¥{detail.tray_amount ?? '0.00'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">商品总金额</Text>
|
||||
<span>¥{detail.product_amount}</span>
|
||||
</div>
|
||||
<div className="mt-1! flex justify-between border-t border-gray-200 pt-2">
|
||||
<Text strong>订单总金额</Text>
|
||||
<Text strong type="danger">
|
||||
¥{detail.total_amount}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
@@ -593,6 +764,87 @@ const StoreOrderPage: React.FC = () => {
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{/* 编辑商品明细(保存后后端重算单品金额与订单总价) */}
|
||||
<Modal
|
||||
title="编辑商品明细"
|
||||
open={itemEditOpen}
|
||||
onCancel={() => setItemEditOpen(false)}
|
||||
onOk={() => itemForm.submit()}
|
||||
confirmLoading={itemSaving}
|
||||
okText="保存"
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
订单 {detail?.order_no};修改保存后,系统将自动重算单品金额与订单总价。
|
||||
</div>
|
||||
<Form form={itemForm} layout="vertical" onFinish={handleItemSave}>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Item
|
||||
label="供应商"
|
||||
name="supplier_id"
|
||||
rules={[{ required: true, message: '请选择供应商' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: 'label'
|
||||
}}
|
||||
placeholder="请选择供应商"
|
||||
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="品名"
|
||||
name="product_name"
|
||||
rules={[{ required: true, message: '请输入品名' }]}
|
||||
>
|
||||
<Input placeholder="请输入品名" maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item label="规格/包规" name="product_spec">
|
||||
<Input placeholder="请输入规格/包规" maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="计价单位"
|
||||
name="unit"
|
||||
rules={[{ required: true, message: '请输入计价单位' }]}
|
||||
>
|
||||
<Input placeholder="斤/件/箱等" maxLength={20} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="单价(元)"
|
||||
name="price"
|
||||
rules={[{ required: true, message: '请输入单价' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="成本价(元)"
|
||||
name="cost_price"
|
||||
rules={[{ required: true, message: '请输入成本价' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入成本价" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="订货量"
|
||||
name="quantity"
|
||||
rules={[{ required: true, message: '请输入订货量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入订货量" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="重量"
|
||||
name="weight"
|
||||
rules={[{ required: true, message: '请输入重量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入重量" />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<TextArea className="w-full" placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user