'待接单', StoreOrderModel::STATUS_SUMMARIZED => '已接单', StoreOrderModel::STATUS_DELIVERING => '采购中', StoreOrderModel::STATUS_DISTRIBUTION => '配送中', StoreOrderModel::STATUS_COMPLETED => '已完成', 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' => '=', 'order_no' => 'like', 'order_date' => 'betweenDate', ]; /** 订单列表(支持按包含的商品名称搜索 ?product_name=) */ #[GetRoute(authorize: 'query')] public function query(Request $request): JsonResponse { $params = $request->all(); $pageSize = $params['pageSize'] ?? 10; $query = StoreOrderModel::query()->with([ 'store:id,name,address,contact,phone', '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) { $item['box_price'] = number_format($box_amount, 2); $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); } return $this->success($data); } /** * 已接单预览:聚合所有已接单订单明细(按商品分组), * 供生成采购单前确认(C1 前置);单位取明细快照 */ #[GetRoute('/summary', 'query')] public function summary(): JsonResponse { $rows = StoreOrderItemModel::query() ->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) { $query->where('status', StoreOrderModel::STATUS_SUMMARIZED); }) ->groupBy('product_id') ->orderBy('product_id') ->get() ->toArray(); // 快照无单位的历史明细兜底:从商品档案(含已下架/软删除)补齐 $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,address,contact,phone', 'items' => static fn ($query) => $query->with('supplier:id,name'), ])->find($id); if (empty($order)) { throw new RepositoryException('订单不存在'); } // 成本价默认对序列化隐藏(防泄漏到小程序端),后台恢复可见 $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> $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()); }); } /** * 状态流转:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成 */ #[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])] public function status(int $id, Request $request): JsonResponse { $data = $request->validate([ 'status' => 'required|integer|in:1,3,4,9', ], [ 'status.required' => '目标状态不能为空', 'status.in' => '目标状态值不正确', ]); $order = StoreOrderModel::find($id); if (empty($order)) { throw new RepositoryException('订单不存在'); } $target = (int) $data['status']; if (! in_array($target, $this->allowedTransitions($order->status), true)) { throw new RepositoryException( '订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许该操作' ); } $order->status = $target; $order->save(); $this->notifyStore($order); return $this->success(); } /** * 批量状态流转:先全量校验流转路径,任一订单不允许则整批中止; * 全部合法时在事务内流转并逐单通知门店 */ #[PutRoute(route: '/batchStatus', authorize: 'update')] public function batchStatus(Request $request): JsonResponse { $data = $request->validate([ 'ids' => 'required|array|min:1', 'ids.*' => 'integer|exists:store_order,id', 'status' => 'required|integer|in:1,3,4,9', ], [ 'ids.required' => '请选择要流转的订单', 'ids.min' => '请选择要流转的订单', 'ids.*.exists' => '订单不存在', 'status.required' => '目标状态不能为空', 'status.in' => '目标状态值不正确', ]); $target = (int) $data['status']; $orders = StoreOrderModel::query()->whereIn('id', $data['ids'])->get(); // 全量预校验:任一订单不满足流转条件,整批中止,不做任何修改 $blocked = []; foreach ($orders as $order) { if (! in_array($target, $this->allowedTransitions($order->status), true)) { $blocked[] = $order->order_no . '(' . (self::STATUS_NAMES[$order->status] ?? $order->status) . ')'; } } if (! empty($blocked)) { throw new RepositoryException( '以下订单不允许流转为「' . (self::STATUS_NAMES[$target] ?? $target) . '」,批量操作已中止:' . implode('、', $blocked) ); } DB::transaction(function () use ($orders, $target) { foreach ($orders as $order) { $order->status = $target; $order->save(); $this->notifyStore($order); } }); return $this->success(['success' => $orders->count()]); } /** * 删除订单(软删除):仅已取消订单允许删除;删除后后台列表/详情、小程序端均不可见 */ #[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])] public function delete(int $id): JsonResponse { $order = StoreOrderModel::find($id); if (empty($order)) { throw new RepositoryException('订单不存在'); } if ($order->status !== StoreOrderModel::STATUS_CANCELLED) { throw new RepositoryException( '订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,仅已取消订单可删除' ); } $order->delete(); return $this->success(); } /** * 状态流转合法路径(与迁移状态定义一致): * 待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成 * (已接单→采购中 只能经「生成采购单」完成,不在本流转内) * * @return int[] */ private function allowedTransitions(int $status): array { return match ($status) { StoreOrderModel::STATUS_PENDING => [ StoreOrderModel::STATUS_SUMMARIZED, StoreOrderModel::STATUS_CANCELLED, ], StoreOrderModel::STATUS_DELIVERING => [ StoreOrderModel::STATUS_DISTRIBUTION, StoreOrderModel::STATUS_COMPLETED, ], StoreOrderModel::STATUS_DISTRIBUTION => [StoreOrderModel::STATUS_COMPLETED], default => [], }; } /** * 修改周转框/周转托盘数量(仅已接单、采购中、配送中可改), * 自动重算附加金额与订单总金额 */ #[PutRoute(route: '/{id}/container', authorize: 'update', where: ['id' => '[0-9]+'])] public function container(int $id, Request $request): JsonResponse { $data = $request->validate([ 'box_num' => 'required|integer|min:0', 'tray_num' => 'required|integer|min:0', ], [ 'box_num.required' => '周转框数量不能为空', 'box_num.integer' => '周转框数量必须为整数', 'box_num.min' => '周转框数量不能小于 0', 'tray_num.required' => '周转托盘数量不能为空', 'tray_num.integer' => '周转托盘数量必须为整数', 'tray_num.min' => '周转托盘数量不能小于 0', ]); $order = StoreOrderModel::find($id); if (empty($order)) { throw new RepositoryException('订单不存在'); } $editable = [ StoreOrderModel::STATUS_SUMMARIZED, StoreOrderModel::STATUS_DELIVERING, StoreOrderModel::STATUS_DISTRIBUTION, ]; if (! in_array($order->status, $editable, true)) { throw new RepositoryException( '订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量' ); } $boxPrice = (float) site_config('services.box_amount', 0); $trayPrice = (float) site_config('services.tray_amount', 0); $addedAmount = round($data['box_num'] * $boxPrice + $data['tray_num'] * $trayPrice, 2); // 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立 $productAmount = (float) $order->product_amount; if ($productAmount <= 0 && (float) $order->total_amount > 0) { $productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2); } $order->box_num = (int) $data['box_num']; $order->tray_num = (int) $data['tray_num']; $order->product_amount = $productAmount; $order->added_amount = $addedAmount; $order->total_amount = round($productAmount + $addedAmount, 2); $order->save(); 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(); } /** * 状态流转后通知门店用户 */ private function notifyStore(StoreOrderModel $order): void { $userIds = UserModel::query() ->where('store_id', $order->store_id) ->where('status', UserModel::STATUS_NORMAL) ->pluck('id'); $statusName = self::STATUS_NAMES[$order->status] ?? (string) $order->status; foreach ($userIds as $userId) { NoticeModel::create([ 'user_id' => $userId, 'type' => NoticeModel::TYPE_ORDER, 'title' => '订单状态更新', 'content' => mb_substr("您的订单 {$order->order_no} 状态已更新为「{$statusName}」", 0, 500), 'data' => ['order_id' => $order->id], 'is_read' => NoticeModel::UNREAD, ]); } } }