订单详情

This commit is contained in:
liu
2026-08-11 23:26:34 +08:00
parent 5d26f04ba2
commit ce958d690f
10 changed files with 996 additions and 70 deletions
@@ -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();
}
/**
* 状态流转后通知门店用户
*/
+47 -1
View File
@@ -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');
}
}