订单详情
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[] = [
|
$rows[] = [
|
||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
'product_id' => $productId,
|
'product_id' => $productId,
|
||||||
|
'category_id' => (int) $product->category_id,
|
||||||
|
'supplier_id' => (int) $product->supplier_id,
|
||||||
'product_name' => $product->name,
|
'product_name' => $product->name,
|
||||||
'product_spec' => $product->spec,
|
'product_spec' => $product->spec,
|
||||||
|
'unit' => (string) $product->unit,
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
|
'image_ids' => implode(',', (array) $product->image_ids),
|
||||||
|
'content' => (string) $product->content,
|
||||||
|
'shelf_life' => (int) $product->shelf_life,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => 0,
|
'weight' => 0,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
|
'cost_price' => (string) $product->cost_price,
|
||||||
'remark' => '',
|
'remark' => '',
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Order;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\NoticeModel;
|
use App\Models\NoticeModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\ProductPriceModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
@@ -15,9 +16,10 @@ use Modules\AnnoRoute\Attribute\GetRoute;
|
|||||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
use Modules\SystemTool\Models\SysFileModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店订单管理(订单只读 + 状态管理;创建/取消在小程序端)
|
* 门店订单管理(订单只读 + 状态管理 + 明细修改/同步;创建/取消在小程序端)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/order/store', 'order.store')]
|
#[RequestAttribute('/order/store', 'order.store')]
|
||||||
class StoreOrderController extends BaseController
|
class StoreOrderController extends BaseController
|
||||||
@@ -32,6 +34,14 @@ class StoreOrderController extends BaseController
|
|||||||
StoreOrderModel::STATUS_CANCELLED => '已取消',
|
StoreOrderModel::STATUS_CANCELLED => '已取消',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 明细可编辑状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
|
||||||
|
private const array ITEM_EDITABLE_STATUS = [
|
||||||
|
StoreOrderModel::STATUS_PENDING,
|
||||||
|
StoreOrderModel::STATUS_SUMMARIZED,
|
||||||
|
StoreOrderModel::STATUS_DELIVERING,
|
||||||
|
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||||
|
];
|
||||||
|
|
||||||
protected array $searchField = [
|
protected array $searchField = [
|
||||||
'store_id' => '=',
|
'store_id' => '=',
|
||||||
'status' => '=',
|
'status' => '=',
|
||||||
@@ -39,7 +49,7 @@ class StoreOrderController extends BaseController
|
|||||||
'order_date' => 'betweenDate',
|
'order_date' => 'betweenDate',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 订单列表 */
|
/** 订单列表(支持按包含的商品名称搜索 ?product_name=) */
|
||||||
#[GetRoute(authorize: 'query')]
|
#[GetRoute(authorize: 'query')]
|
||||||
public function query(Request $request): JsonResponse
|
public function query(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -47,14 +57,36 @@ class StoreOrderController extends BaseController
|
|||||||
$pageSize = $params['pageSize'] ?? 10;
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
$query = StoreOrderModel::query()->with([
|
$query = StoreOrderModel::query()->with([
|
||||||
'store:id,name,address,contact,phone',
|
'store:id,name,address,contact,phone',
|
||||||
'items:id,order_id,product_id,product_name,product_spec,price,quantity,amount',
|
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
||||||
'items.product'
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
||||||
|
$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)
|
$data = $this->buildSearch($params, $query)
|
||||||
->orderBy('order_date', 'desc')
|
->orderBy('order_date', 'desc')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate($pageSize)
|
->paginate($pageSize)
|
||||||
->toArray();
|
->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');
|
$box_amount = site_config('services.box_amount');
|
||||||
$tray_amount = site_config('services.tray_amount');
|
$tray_amount = site_config('services.tray_amount');
|
||||||
foreach ($data['data'] as &$item) {
|
foreach ($data['data'] as &$item) {
|
||||||
@@ -62,21 +94,13 @@ class StoreOrderController extends BaseController
|
|||||||
$item['tray_price'] = number_format($tray_amount, 2);
|
$item['tray_price'] = number_format($tray_amount, 2);
|
||||||
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
|
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
|
||||||
$item['tray_amount'] = number_format($tray_amount * $item['tray_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);
|
return $this->success($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 已接单预览:聚合所有已接单订单明细(按商品分组),
|
* 已接单预览:聚合所有已接单订单明细(按商品分组),
|
||||||
* 供生成采购单前确认(C1 前置)
|
* 供生成采购单前确认(C1 前置);单位取明细快照
|
||||||
*/
|
*/
|
||||||
#[GetRoute('/summary', 'query')]
|
#[GetRoute('/summary', 'query')]
|
||||||
public function summary(): JsonResponse
|
public function summary(): JsonResponse
|
||||||
@@ -85,6 +109,7 @@ class StoreOrderController extends BaseController
|
|||||||
->select('product_id')
|
->select('product_id')
|
||||||
->selectRaw('MAX(product_name) as product_name')
|
->selectRaw('MAX(product_name) as product_name')
|
||||||
->selectRaw('MAX(product_spec) as product_spec')
|
->selectRaw('MAX(product_spec) as product_spec')
|
||||||
|
->selectRaw('MAX(unit) as unit')
|
||||||
->selectRaw('SUM(quantity) as total_quantity')
|
->selectRaw('SUM(quantity) as total_quantity')
|
||||||
->selectRaw('COUNT(DISTINCT store_id) as store_count')
|
->selectRaw('COUNT(DISTINCT store_id) as store_count')
|
||||||
->whereHas('order', function ($query) {
|
->whereHas('order', function ($query) {
|
||||||
@@ -95,26 +120,205 @@ class StoreOrderController extends BaseController
|
|||||||
->get()
|
->get()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
// 补充计价单位(商品档案,含已下架/软删除)
|
// 快照无单位的历史明细兜底:从商品档案(含已下架/软删除)补齐
|
||||||
$units = ProductModel::withTrashed()
|
$emptyUnitProductIds = array_column(array_filter(
|
||||||
->whereIn('id', array_column($rows, 'product_id'))
|
$rows,
|
||||||
->pluck('unit', 'id');
|
static fn (array $row) => ($row['unit'] ?? '') === ''
|
||||||
foreach ($rows as &$row) {
|
), 'product_id');
|
||||||
$row['unit'] = $units[$row['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);
|
return $this->success($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订单详情:订单头 + 明细(含商品快照) */
|
/** 订单详情:订单头 + 明细(含商品快照、首图;后台侧成本价可见、附供应商名) */
|
||||||
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||||
public function detail(int $id): JsonResponse
|
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)) {
|
if (empty($order)) {
|
||||||
throw new RepositoryException('订单不存在');
|
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();
|
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;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店订单明细模型(快照下单时的商品名/规格/等级价,历史单据不受调价影响)
|
* 门店订单明细模型(下单时快照商品档案:品名/规格/单位/供应商/图文/成本价 + 等级单价,
|
||||||
|
* 商品调价或档案变更不影响历史单据)
|
||||||
*/
|
*/
|
||||||
class StoreOrderItemModel extends Model
|
class StoreOrderItemModel extends Model
|
||||||
{
|
{
|
||||||
@@ -20,12 +22,19 @@ class StoreOrderItemModel extends Model
|
|||||||
'order_id',
|
'order_id',
|
||||||
'store_id',
|
'store_id',
|
||||||
'product_id',
|
'product_id',
|
||||||
|
'category_id',
|
||||||
|
'supplier_id',
|
||||||
'product_name',
|
'product_name',
|
||||||
'product_spec',
|
'product_spec',
|
||||||
|
'unit',
|
||||||
'price',
|
'price',
|
||||||
|
'image_ids',
|
||||||
|
'content',
|
||||||
|
'shelf_life',
|
||||||
'quantity',
|
'quantity',
|
||||||
'weight',
|
'weight',
|
||||||
'amount',
|
'amount',
|
||||||
|
'cost_price',
|
||||||
'remark',
|
'remark',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,12 +42,33 @@ class StoreOrderItemModel extends Model
|
|||||||
'order_id' => 'integer',
|
'order_id' => 'integer',
|
||||||
'store_id' => 'integer',
|
'store_id' => 'integer',
|
||||||
'product_id' => 'integer',
|
'product_id' => 'integer',
|
||||||
|
'category_id' => 'integer',
|
||||||
|
'supplier_id' => 'integer',
|
||||||
'price' => 'decimal:2',
|
'price' => 'decimal:2',
|
||||||
|
'shelf_life' => 'integer',
|
||||||
'quantity' => 'integer',
|
'quantity' => 'integer',
|
||||||
'weight' => 'decimal:3',
|
'weight' => 'decimal:3',
|
||||||
'amount' => 'decimal:2',
|
'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');
|
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,
|
'order_id' => 0,
|
||||||
'store_id' => 0,
|
'store_id' => 0,
|
||||||
'product_id' => 0,
|
'product_id' => 0,
|
||||||
|
'category_id' => 0,
|
||||||
|
'supplier_id' => 0,
|
||||||
'product_name' => '测试商品',
|
'product_name' => '测试商品',
|
||||||
'product_spec' => '500g/袋',
|
'product_spec' => '500g/袋',
|
||||||
|
'unit' => '斤',
|
||||||
'price' => number_format(random_int(100, 5000) / 100, 2, '.', ''),
|
'price' => number_format(random_int(100, 5000) / 100, 2, '.', ''),
|
||||||
|
'image_ids' => '',
|
||||||
|
'content' => '',
|
||||||
|
'shelf_life' => 0,
|
||||||
'quantity' => number_format(random_int(100, 10000) / 100, 2, '.', ''),
|
'quantity' => number_format(random_int(100, 10000) / 100, 2, '.', ''),
|
||||||
'weight' => 0,
|
'weight' => 0,
|
||||||
'amount' => 0,
|
'amount' => 0,
|
||||||
|
'cost_price' => 0,
|
||||||
'remark' => '',
|
'remark' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,21 @@ return new class extends Migration
|
|||||||
Schema::create('store_order_item', function (Blueprint $table) {
|
Schema::create('store_order_item', function (Blueprint $table) {
|
||||||
$table->increments('id')->comment('明细ID');
|
$table->increments('id')->comment('明细ID');
|
||||||
$table->integer('order_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->integer('product_id')->comment('商品ID');
|
||||||
$table->string('product_name', 100)->comment('品名(快照)');
|
$table->integer('category_id')->default(0)->comment('分类ID');
|
||||||
$table->string('product_spec', 100)->default('')->comment('规格/包规(快照)');
|
$table->integer('supplier_id')->default(0)->comment('供应商ID');
|
||||||
$table->decimal('price', 10, 2)->default(0)->comment('单价(下单时客户等级价快照)');
|
$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->integer('quantity')->default(0)->comment('订货量');
|
||||||
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
|
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
|
||||||
$table->decimal('amount', 10, 2)->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->string('remark', 255)->default('')->comment('门店下单备注');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->index(['order_id'], 'store_order_item_order_index');
|
$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 createAxios from '@/utils/request';
|
||||||
import type IStoreOrder from '@/domain/iStoreOrder.ts';
|
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) {
|
export async function getStoreOrder(id: number) {
|
||||||
@@ -44,3 +44,20 @@ export async function getOrderSummary() {
|
|||||||
method: 'get',
|
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 {
|
export interface IStoreOrderItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
order_id?: number;
|
order_id?: number;
|
||||||
store_id?: number;
|
store_id?: number;
|
||||||
product_id?: number;
|
product_id?: number;
|
||||||
|
category_id?: number;
|
||||||
|
supplier_id?: number;
|
||||||
|
/** 详情接口附带的供应商信息 */
|
||||||
|
supplier?: { id: number; name: string };
|
||||||
product_name?: string;
|
product_name?: string;
|
||||||
product_spec?: string;
|
product_spec?: string;
|
||||||
|
/** 计价单位(斤/件/箱等) */
|
||||||
|
unit?: string;
|
||||||
price?: string;
|
price?: string;
|
||||||
|
/** 列表接口附带的封面图 */
|
||||||
image?: string;
|
image?: string;
|
||||||
|
image_ids?: string;
|
||||||
|
shelf_life?: number;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
weight?: string;
|
weight?: string;
|
||||||
amount?: string;
|
amount?: string;
|
||||||
|
/** 成本价(仅后台接口可见) */
|
||||||
|
cost_price?: string;
|
||||||
remark?: 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 {
|
export default interface IStoreOrder {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|||||||
+292
-40
@@ -4,20 +4,21 @@ import {
|
|||||||
DatePicker,
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
message,
|
message,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Radio,
|
Radio,
|
||||||
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
Image,
|
Image,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { TableProps } from 'antd';
|
|
||||||
import XinTable from '@/components/XinTable';
|
import XinTable from '@/components/XinTable';
|
||||||
import type {
|
import type {
|
||||||
XinTableColumn,
|
XinTableColumn,
|
||||||
@@ -25,20 +26,33 @@ import type {
|
|||||||
XinTableProps,
|
XinTableProps,
|
||||||
} from '@/components/XinTable/typings.ts';
|
} from '@/components/XinTable/typings.ts';
|
||||||
import type IStoreOrder from '@/domain/iStoreOrder.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 { 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 { generatePurchase } from '@/api/purchase/order.ts';
|
||||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||||
|
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||||
import type IStore from '@/domain/iStore.ts';
|
import type IStore from '@/domain/iStore.ts';
|
||||||
|
import type ISupplier from '@/domain/iSupplier.ts';
|
||||||
import AuthButton from '@/components/AuthButton';
|
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 { Title, Text } = Typography;
|
||||||
|
|
||||||
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
|
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
|
||||||
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
|
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 [generateLoading, setGenerateLoading] = useState(false);
|
||||||
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
|
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(() => {
|
useEffect(() => {
|
||||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||||
|
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openDetail = async (id: number) => {
|
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 watchBoxNum = Number(Form.useWatch('box_num', containerForm) ?? 0);
|
||||||
const watchTrayNum = Number(Form.useWatch('tray_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);
|
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
|
||||||
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
|
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
|
||||||
|
|
||||||
const itemColumns: TableProps<IStoreOrderItem>['columns'] = [
|
/** 明细行编辑/同步按钮(仅未完成、未取消订单可见) */
|
||||||
{ title: '品名', dataIndex: 'product_name' },
|
const itemEditable = ITEM_EDITABLE_STATUS.includes(detail?.status ?? -1);
|
||||||
{ 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 columns: XinTableColumn<IStoreOrder>[] = [
|
const columns: XinTableColumn<IStoreOrder>[] = [
|
||||||
{
|
{
|
||||||
@@ -189,6 +247,13 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
valueType: 'text',
|
valueType: 'text',
|
||||||
hideInForm: true
|
hideInForm: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '商品名称',
|
||||||
|
hideInTable: true,
|
||||||
|
dataIndex: 'product_name',
|
||||||
|
valueType: 'text',
|
||||||
|
hideInForm: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '商品信息',
|
title: '商品信息',
|
||||||
dataIndex: 'items',
|
dataIndex: 'items',
|
||||||
@@ -352,7 +417,7 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space orientation={'vertical'}>
|
<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>
|
</Button>
|
||||||
{
|
{
|
||||||
@@ -362,7 +427,7 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
title={`确认将订单状态更新为「${action.label}」?`}
|
title={`确认将订单状态更新为「${action.label}」?`}
|
||||||
onConfirm={() => handleStatusChange(record.id!, action.status)}
|
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}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
@@ -372,7 +437,7 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) ? (
|
CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) ? (
|
||||||
<AuthButton key="container" auth="order.store.update">
|
<AuthButton key="container" auth="order.store.update">
|
||||||
<Button type={'primary'} onClick={() => openContainer(record)}>
|
<Button type={'link'} onClick={() => openContainer(record)}>
|
||||||
设置附加信息
|
设置附加信息
|
||||||
</Button>
|
</Button>
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
@@ -429,7 +494,7 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
title="订单详情"
|
title="订单详情"
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
width={720}
|
size={1000}
|
||||||
loading={detailLoading}
|
loading={detailLoading}
|
||||||
footer={
|
footer={
|
||||||
detail && NEXT_STATUS[detail.status ?? -1] ? (
|
detail && NEXT_STATUS[detail.status ?? -1] ? (
|
||||||
@@ -452,9 +517,21 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{detail ? (
|
{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.order_no}</Descriptions.Item>
|
||||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="订货日期">{detail.order_date}</Descriptions.Item>
|
<Descriptions.Item label="订货日期">{detail.order_date}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.color}>
|
<Tag color={STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||||
@@ -462,34 +539,128 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
</Tag>
|
</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="订货总量">{detail.total_quantity}</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 ? (
|
{detail.remark ? (
|
||||||
<Descriptions.Item label="备注" span={2}>
|
<Descriptions.Item label="备注" span={3}>
|
||||||
{detail.remark}
|
{detail.remark}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
) : null}
|
) : null}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Title level={5} className="!mt-6 !mb-3">
|
|
||||||
|
{/* 商品明细(商城模式:首图 + 单价 × 订货量 + 金额) */}
|
||||||
|
<Title level={5} className="mt-6! mb-3!">
|
||||||
商品明细
|
商品明细
|
||||||
</Title>
|
</Title>
|
||||||
<Table<IStoreOrderItem>
|
<div className="overflow-hidden rounded border border-gray-200">
|
||||||
rowKey="id"
|
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
|
||||||
size="small"
|
<div className="flex-1">商品信息</div>
|
||||||
columns={itemColumns}
|
<div className="w-30 shrink-0 text-center">供应商</div>
|
||||||
dataSource={detail.items ?? []}
|
<div className="w-30 shrink-0 text-center">单价</div>
|
||||||
pagination={false}
|
<div className="w-26 shrink-0 text-center">订货量</div>
|
||||||
summary={() => (
|
<div className="w-33 shrink-0 text-center">金额</div>
|
||||||
<Table.Summary.Row>
|
{itemEditable ? <div className="w-40 shrink-0 text-center">操作</div> : null}
|
||||||
<Table.Summary.Cell index={0} colSpan={4} align="right">
|
</div>
|
||||||
合计
|
{(detail.items ?? []).map((item) => (
|
||||||
</Table.Summary.Cell>
|
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
|
||||||
<Table.Summary.Cell index={1} align="right">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
<Text strong>¥{detail.total_amount}</Text>
|
<Image.PreviewGroup>
|
||||||
</Table.Summary.Cell>
|
{item.image ? (
|
||||||
<Table.Summary.Cell index={2} />
|
<Image
|
||||||
</Table.Summary.Row>
|
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}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -593,6 +764,87 @@ const StoreOrderPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</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