'=', 'purchase_no' => 'like', 'purchase_date' => 'betweenDate', ]; /** 采购单列表 */ #[GetRoute(authorize: 'query')] public function query(Request $request): JsonResponse { $params = $request->all(); $pageSize = $params['pageSize'] ?? 10; $data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname')) ->orderBy('purchase_date', 'desc') ->orderBy('id', 'desc') ->paginate($pageSize) ->toArray(); return $this->success($data); } /** * 采购单详情 */ #[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])] public function detail(int $id): JsonResponse { $purchase = PurchaseOrderModel::with('operator:id,nickname')->find($id); if (empty($purchase)) { return $this->error('采购单不存在'); } // 订货明细 $orderItems = StoreOrderItemModel::query() ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') ->where('store_order_item.purchase_id', $purchase->id) ->whereNull('store_order.deleted_at') ->with('supplier:id,name') ->select('store_order_item.*') ->get() ->makeVisible('cost_price'); // 门店列(含软删除门店,保证历史单据可见) $stores = StoreModel::withTrashed() ->whereIn('id', $orderItems->pluck('store_id')->unique()) ->get(['id', 'name']) ->map(static fn (StoreModel $store) => ['id' => $store->id, 'name' => $store->name]) ->values(); // 排序键:分类 sort → 商品 sort $products = ProductModel::withTrashed() ->with('category:id,sort') ->whereIn('id', $orderItems->pluck('product_id')->unique()) ->get() ->keyBy('id'); $rows = []; foreach ($orderItems->groupBy('product_id') as $productId => $group) { $first = $group->first(); // 商品 $product = $products->get((int) $productId); // 采购总数 $quantity = 0; // 采购总重量 $weight = '0'; // 采购总金额(Σ明细 amount,单价按 金额÷数量 加权) $amount = '0'; // 门店明细 $cellsMap = []; foreach ($group as $item) { $storeId = $item->store_id; // 累加总量 $quantity += (int) $item->quantity; $weight = bcadd($weight, (string) $item->weight, 3); $amount = bcadd($amount, (string) $item->amount, 2); // 按门店合并 if (!isset($cellsMap[$storeId])) { $cellsMap[$storeId] = 0; } $cellsMap[$storeId] += (int) $item->quantity; } // 供应商 $supplier = $first->supplier ? ['id' => $first->supplier->id, 'name' => $first->supplier->name] : null; $rows[] = [ 'product_id' => (int) $productId, 'product_name' => $first->product_name, // 品名 'product_spec' => $first->product_spec, // 包规 'unit' => $first->unit, // 单位 'supplier_id' => (int) $first->supplier_id, // 供应商id 'supplier' => $supplier, // 供应商 'market' => (string) ($product?->market ?? ''), // 市场(取商品档案) 'cost_price' => $first->cost_price, // 成本价 'quantity' => $quantity, 'weight' => (float) $weight, 'amount' => $amount, 'cells' => $cellsMap, 'category_sort' => (int) ($product->category->sort ?? 9999), 'product_sort' => (int) ($product->sort ?? 9999), ]; } usort($rows, static fn (array $a, array $b): int => [$a['category_sort'], $a['product_sort'], $a['product_id']] <=> [$b['category_sort'], $b['product_sort'], $b['product_id']]); // 门店账单:采购单完成后按门店生成(含软删除门店,保证历史单据可见) $bills = BillModel::query() ->where('purchase_id', $purchase->id) ->with('store:id,name') ->orderBy('id') ->get() ->map(static function (BillModel $bill): array { $row = $bill->toArray(); $row['store_name'] = $bill->store->name ?? ('门店#' . $bill->store_id); $row['order_count'] = $bill->orders()->count(); unset($row['store']); return $row; }) ->values() ->toArray(); return $this->success([ 'purchase' => $purchase->toArray(), 'stores' => $stores->toArray(), 'items' => array_map(static function (array $row): array { unset($row['category_sort'], $row['product_sort']); return $row; }, $rows), 'bills' => $bills, // 单品「已对账」标记(入库持久化,商品ID列表) 'checked_product_ids' => PurchaseItemCheckModel::query() ->where('purchase_id', $purchase->id) ->pluck('product_id') ->map(static fn ($v) => (int) $v) ->all(), ]); } /** 修改采购单信息(采购日期、实际金额、备注) */ #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] public function update(int $id, Request $request): JsonResponse { $data = $request->validate([ 'actual_amount' => 'required|numeric|min:0', 'remark' => 'nullable|string|max:255', ], [ 'actual_amount.required' => '实际金额不能为空', 'actual_amount.numeric' => '实际金额格式错误', 'actual_amount.min' => '实际金额不能小于0', 'remark.max' => '备注超过最大长度', ]); $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) { throw new RepositoryException('采购单已完成,不允许修改信息'); } $purchase->update(array_filter($data, static fn ($v) => $v !== null)); return $this->success(); } /** * 完成采购单 * @throws */ #[PutRoute(route: '/{id}/finish', authorize: 'update', where: ['id' => '[0-9]+'])] public function finish(int $id): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) { throw new RepositoryException('采购单已完成,不允许修改信息'); } return DB::transaction(function () use ($purchase) { // 修改采购单状态 $purchase->status = PurchaseOrderModel::STATUS_COMPLETED; $purchase->save(); // 修改所有订单状态为配送中 StoreOrderModel::where('purchase_id', $purchase->id)->update([ 'status' => StoreOrderModel::STATUS_DISTRIBUTION ]); return $this->success(); }); } /** * 生成采购单 */ #[PostRoute('/generate', 'generate')] public function generate(Request $request): JsonResponse { $data = $request->validate([ 'purchase_date' => 'required|date_format:Y-m-d', 'order_ids' => 'sometimes|array|min:1', 'order_ids.*' => 'integer|exists:store_order,id', ], [ 'purchase_date.required' => '请选择采购日期', 'purchase_date.date_format' => '采购日期格式为 Y-m-d', 'order_ids.min' => '请选择要合并的订单', 'order_ids.*.exists' => '订单不存在', ]); $purchase = app(PurchaseGenerateService::class)->generate( $data['purchase_date'], (int) $request->user()->id, array_map('intval', $data['order_ids'] ?? []), ); return $this->success( ['id' => $purchase->id, 'purchase_no' => $purchase->purchase_no], '采购单已生成' ); } /** * 导出采购单商品明细 Excel 表格(系统全部商品行,支持按供应商筛选) * * @throws */ #[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])] public function export(int $id, Request $request): Response { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $supplierId = (int) $request->query('supplier_id', 0); $filename = $purchase->purchase_no . '_采购单.xlsx'; if ($supplierId > 0) { $supplier = SupplierModel::withTrashed()->find($supplierId); $filename = $purchase->purchase_no . '_采购单_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx'; } return Excel::download(new PurchaseOrderExport($purchase, $supplierId), $filename); } /** * 导出门店购买详情(多工作表:每门店一个工作表;?store_id= 单门店导出) */ #[GetRoute(route: '/{id}/exportStores', authorize: 'export', where: ['id' => '[0-9]+'])] public function exportStores(int $id, Request $request): Response { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $storeId = (int) $request->query('store_id', 0); $filename = $purchase->purchase_no . '_门店购买详情.xlsx'; if ($storeId > 0) { $store = StoreModel::withTrashed()->find($storeId); $filename = $purchase->purchase_no . '_门店购买详情_' . ($store->name ?? ('门店' . $storeId)) . '.xlsx'; } return Excel::download(new PurchaseStoreExport($purchase, $storeId), $filename); } /** * 导出供应商采购明细(多工作表:每供应商一个工作表;?supplier_id= 单供应商导出) */ #[GetRoute(route: '/{id}/exportSuppliers', authorize: 'export', where: ['id' => '[0-9]+'])] public function exportSuppliers(int $id, Request $request): Response { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $supplierId = (int) $request->query('supplier_id', 0); $filename = $purchase->purchase_no . '_供应商采购明细.xlsx'; if ($supplierId > 0) { $supplier = SupplierModel::withTrashed()->find($supplierId); $filename = $purchase->purchase_no . '_供应商采购明细_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx'; } return Excel::download(new PurchaseSupplierExport($purchase, $supplierId), $filename); } /** * 单元格下钻:采购单中某门店某商品的全部订货明细 */ #[GetRoute(route: '/{id}/cell', authorize: 'query', where: ['id' => '[0-9]+'])] public function cell(int $id, Request $request): JsonResponse { $productId = (int) $request->query('product_id', 0); $storeId = (int) $request->query('store_id', 0); if ($productId <= 0 || $storeId <= 0) { throw new RepositoryException('缺少商品或门店参数'); } $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $items = StoreOrderItemModel::query() ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') ->where('store_order_item.purchase_id', $purchase->id) ->where('store_order_item.product_id', $productId) ->where('store_order_item.store_id', $storeId) ->whereNull('store_order.deleted_at') ->with('supplier:id,name') ->select( 'store_order_item.*', 'store_order.order_no', 'store_order.order_date', 'store_order.status as order_status', ) ->orderBy('store_order.id') ->get() ->makeVisible('cost_price'); $rows = []; foreach ($items as $item) { $rows[] = [ 'id' => $item->id, 'order_id' => $item->order_id, 'order_no' => $item->order_no, 'order_date' => (string) $item->order_date, 'order_status' => (int) $item->order_status, 'product_name' => $item->product_name, 'product_spec' => $item->product_spec, 'unit' => $item->unit, 'supplier_id' => (int) $item->supplier_id, 'supplier' => $item->supplier ? ['id' => $item->supplier->id, 'name' => $item->supplier->name] : null, 'price' => (string) $item->price, 'cost_price' => (string) $item->cost_price, 'quantity' => (int) $item->quantity, 'weight' => (string) $item->weight, 'amount' => (string) $item->amount, 'remark' => (string) $item->remark, 'image_ids' => $item->image_ids, ]; } app(ItemImageResolver::class)->resolve($rows); $store = StoreModel::withTrashed()->find($storeId); $product = ProductModel::withTrashed()->find($productId); return $this->success([ 'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null, 'product' => $product ? ['id' => $product->id, 'name' => $product->name] : null, 'items' => $rows, ]); } /** * 门店购买详情:采购单内指定门店的采购汇总(按商品聚合) * 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=预计金额 */ #[GetRoute(route: '/{id}/store', authorize: 'query', where: ['id' => '[0-9]+'])] public function storeSummary(int $id, Request $request): JsonResponse { $storeId = (int) $request->query('store_id', 0); if ($storeId <= 0) { throw new RepositoryException('缺少门店参数'); } $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $store = StoreModel::withTrashed()->find($storeId); return $this->success([ 'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null, 'items' => app(PurchaseItemService::class)->storeSummaryRows($purchase->id, $storeId), ]); } /** * 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单, * 单价按门店等级上浮比例换算,无等级按成本价兜底) * * @throws Throwable */ #[PostRoute(route: '/{id}/store/{storeId}/item', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])] public function storeItemStore(int $id, int $storeId, PurchaseStoreItemRequest $request): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $this->assertPurchaseEditable($purchase); $validated = $request->validated(); $productId = (int) $validated['product_id']; $product = ProductModel::find($productId); if ($product === null) { throw new RepositoryException('商品不存在或已被删除,无法添加'); } return DB::transaction(function () use ($purchase, $storeId, $product, $validated) { // 该门店在采购单中的最新一笔订单 $order = StoreOrderModel::query() ->where('purchase_id', $purchase->id) ->where('store_id', $storeId) ->orderByDesc('id') ->lockForUpdate() ->first(); if ($order === null) { throw new RepositoryException('该门店不在此采购单中,无法添加单品'); } $duplicated = StoreOrderItemModel::query() ->where('purchase_id', $purchase->id) ->where('store_id', $storeId) ->where('product_id', $product->id) ->exists(); if ($duplicated) { throw new RepositoryException('该商品已在此门店采购明细中,请直接修改数量'); } // 单价:门店等级上浮换算价(无等级按成本价兜底) $store = StoreModel::withTrashed()->find($storeId); $level = ($store !== null && $store->level_id > 0) ? $store->level : null; $price = $level !== null ? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent) : bcadd((string) $product->cost_price, '0', 2); $quantity = (int) $validated['quantity']; $item = StoreOrderItemModel::create([ 'order_id' => $order->id, 'purchase_id' => $purchase->id, 'bill_id' => 0, 'store_id' => $storeId, 'product_id' => $product->id, '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' => isset($validated['weight']) ? bcadd((string) $validated['weight'], '0', 3) : WeightEstimator::estimate((string) $product->spec, (string) $product->unit, (string) $quantity), 'amount' => bcmul($price, (string) $quantity, 2), 'cost_price' => (string) $product->cost_price, 'remark' => '', ]); $service = app(PurchaseItemService::class); $service->recalcOrder($order->id); $service->recalcPurchase($purchase->id); return $this->success(['id' => $item->id], '已添加单品'); }); } /** * 门店购买详情:修改单品(数量/称重/单价);同门店同商品多笔订单明细时合并到最早一条 * * @throws Throwable */ #[PutRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])] public function storeItemUpdate(int $id, int $storeId, int $productId, PurchaseStoreItemRequest $request): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $this->assertPurchaseEditable($purchase); $validated = $request->validated(); return DB::transaction(function () use ($purchase, $storeId, $productId, $validated) { $items = StoreOrderItemModel::query() ->where('purchase_id', $purchase->id) ->where('store_id', $storeId) ->where('product_id', $productId) ->orderBy('id') ->lockForUpdate() ->get(); if ($items->isEmpty()) { throw new RepositoryException('该门店在此采购单中无此商品明细'); } $affectedOrderIds = $items->pluck('order_id')->unique()->all(); // 多笔订单明细合并到最早一条 $survivor = $items->first(); foreach ($items->skip(1) as $extra) { $extra->delete(); } $survivor->quantity = (int) $validated['quantity']; if (isset($validated['price'])) { $survivor->price = bcadd((string) $validated['price'], '0', 2); } if (isset($validated['weight'])) { $survivor->weight = bcadd((string) $validated['weight'], '0', 3); } $survivor->amount = bcmul((string) $survivor->quantity, (string) $survivor->price, 2); $survivor->save(); $service = app(PurchaseItemService::class); foreach ($affectedOrderIds as $orderId) { $service->recalcOrder((int) $orderId); } $service->recalcPurchase($purchase->id); return $this->success([], '单品已更新'); }); } /** * 门店购买详情:移除单品(该门店此商品的全部订货明细一并删除) * * @throws Throwable */ #[DeleteRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])] public function storeItemDelete(int $id, int $storeId, int $productId): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $this->assertPurchaseEditable($purchase); DB::transaction(function () use ($purchase, $storeId, $productId) { $items = StoreOrderItemModel::query() ->where('purchase_id', $purchase->id) ->where('store_id', $storeId) ->where('product_id', $productId) ->lockForUpdate() ->get(); if ($items->isEmpty()) { throw new RepositoryException('该门店在此采购单中无此商品明细'); } $affectedOrderIds = $items->pluck('order_id')->unique()->all(); foreach ($items as $item) { $item->delete(); } $service = app(PurchaseItemService::class); foreach ($affectedOrderIds as $orderId) { $service->recalcOrder((int) $orderId); } $service->recalcPurchase($purchase->id); }); return $this->success([], '已移除单品'); } /** * 账单生成预览 */ #[GetRoute(route: '/{id}/bill/prepare', authorize: 'query', where: ['id' => '[0-9]+'])] public function billPrepare(int $id): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $orders = StoreOrderModel::query() ->where('purchase_id', $purchase->id) ->whereNull('deleted_at') ->orderBy('id') ->get(['id', 'store_id', 'total_amount']); $stores = StoreModel::withTrashed() ->whereIn('id', $orders->pluck('store_id')->unique()) ->get(['id', 'name']) ->keyBy('id'); $bills = BillModel::query() ->where('purchase_id', $purchase->id) ->get() ->keyBy('store_id'); $boxPrice = number_format((float) site_config('services.box_amount', 0), 2); $trayPrice = number_format((float) site_config('services.tray_amount', 0), 2); $rows = []; foreach ($orders->groupBy('store_id') as $storeId => $storeOrders) { $productAmount = $storeOrders->reduce( static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2), '0' ); $bill = $bills->get((int) $storeId); $rows[] = [ 'store_id' => (int) $storeId, 'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId), 'order_count' => $storeOrders->count(), 'product_amount' => $productAmount, 'box_price' => $boxPrice, 'tray_price' => $trayPrice, 'bill' => $bill?->toArray(), ]; } return $this->success([ 'purchase' => $purchase->only(['id', 'purchase_no', 'status']), 'stores' => $rows, ]); } /** * 生成账单 * @throws Throwable */ #[PostRoute(route: '/{id}/bill', authorize: 'bill', where: ['id' => '[0-9]+'])] public function generateBill(int $id, PurchaseBillGenerateRequest $request): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } if ($purchase->status !== PurchaseOrderModel::STATUS_COMPLETED) { throw new RepositoryException('采购单未完成,不允许生成账单'); } $bills = app(BillGenerateService::class)->generate( $purchase, $request->validated()['stores'], (int) $request->user()->id, ); return $this->success(['count' => count($bills)], '已生成 ' . count($bills) . ' 张门店账单'); } /** * 商品行修改:品名/供应商/包规/单位/成本(同步更新商品档案;商品已删除则跳过档案同步) * 成本价变化时,按各门店客户等级的上浮比例重算明细单价与金额(无等级按成本价兜底), * 并级联重算涉及订单与采购单汇总 * @throws Throwable */ #[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])] public function updateRow(int $id, int $productId, PurchaseRowUpdateRequest $request): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $this->assertPurchaseEditable($purchase); $validated = $request->validated(); return DB::transaction(function () use ($purchase, $productId, $validated) { $items = StoreOrderItemModel::query() ->where('purchase_id', $purchase->id) ->where('product_id', $productId) ->lockForUpdate() ->get(); if ($items->isEmpty()) { throw new RepositoryException('该采购单下无此商品的订货明细'); } // 成本价是否变化(未变化时保留明细单价,避免覆盖单元格/单品的手工改价) $newCost = bcadd((string) $validated['cost_price'], '0', 2); $costChanged = bccomp($newCost, bcadd((string) $items->first()->cost_price, '0', 2), 2) !== 0; // 成本变化 → 按门店等级上浮比例重算单价(一次性取回涉及门店的等级,避免逐行查询) $levelPercents = []; if ($costChanged) { $levelPercents = StoreModel::withTrashed() ->with('level:id,percent') ->whereIn('id', $items->pluck('store_id')->unique()) ->get(['id', 'level_id']) ->mapWithKeys(static fn (StoreModel $store) => [ $store->id => (float) ($store->level?->percent ?? 0), ]) ->all(); } foreach ($items as $item) { $item->fill($validated); if ($costChanged) { $item->price = CustomerLevelModel::calcLevelPrice( $newCost, $levelPercents[(int) $item->store_id] ?? 0, ); $item->amount = bcmul($item->price, (string) $item->quantity, 2); } $item->save(); } // 同步保存到商品档案(软删除商品跳过,明细照常更新) $product = ProductModel::find($productId); $productSynced = $product !== null; if ($productSynced) { $product->update([ 'name' => $validated['product_name'], 'supplier_id' => $validated['supplier_id'], 'spec' => $validated['product_spec'], 'unit' => $validated['unit'], 'cost_price' => $validated['cost_price'], ]); } $service = app(PurchaseItemService::class); // 成本变化会引起明细金额变动,涉及订单需逐一重算 if ($costChanged) { foreach ($items->pluck('order_id')->unique() as $orderId) { $service->recalcOrder((int) $orderId); } } $service->recalcPurchase($purchase->id); $message = '已同步 ' . $items->count() . ' 条订货明细' . ($productSynced ? '与商品档案' : ';商品已删除,档案未同步') . ($costChanged ? ';门店单价已按等级上浮比例重算' : ''); return $this->success(['count' => $items->count()], $message); }); } /** * 门店订单明细修改(订货量、重量、单价),自动重算价格 * @throws Throwable */ #[PutRoute(route: '/cell/{itemId}', authorize: 'update', where: ['itemId' => '[0-9]+'])] public function cellUpdate(int $itemId, PurchaseCellUpdateRequest $request): JsonResponse { $validated = $request->validated(); return DB::transaction(function () use ($itemId, $validated) { $item = StoreOrderItemModel::query()->lockForUpdate()->find($itemId); if (empty($item) || (int) $item->purchase_id === 0) { throw new RepositoryException('采购单明细不存在'); } $purchase = PurchaseOrderModel::find((int) $item->purchase_id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $this->assertPurchaseEditable($purchase); $item->quantity = $validated['quantity']; $item->price = $validated['price']; if (array_key_exists('weight', $validated)) { $item->weight = bcadd((string) $validated['weight'], '0', 3); } $item->amount = bcmul((string) $validated['quantity'], (string) $validated['price'], 2); $item->save(); $service = app(PurchaseItemService::class); $service->recalcOrder((int) $item->order_id); $service->recalcPurchase($purchase->id); return $this->success(); }); } /** * 切换单品「已对账」标记(入库持久化:未标记→标记,已标记→取消;与采购单状态无关,对账期间可反复勾选) */ #[PutRoute(route: '/{id}/check/{productId}', authorize: 'query', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])] public function toggleItemCheck(int $id, int $productId, Request $request): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } $hasItem = StoreOrderItemModel::query() ->where('purchase_id', $purchase->id) ->where('product_id', $productId) ->exists(); if (! $hasItem) { throw new RepositoryException('该采购单下无此商品的订货明细'); } $marked = PurchaseItemCheckModel::query() ->where('purchase_id', $purchase->id) ->where('product_id', $productId) ->first(); if ($marked !== null) { $marked->delete(); return $this->success(['checked' => false], '已取消对账标记'); } PurchaseItemCheckModel::create([ 'purchase_id' => $purchase->id, 'product_id' => $productId, 'operator_id' => (int) $request->user()->id, ]); return $this->success(['checked' => true], '已标记为已对账'); } /** * 批量设置单品「已对账」标记(全选用:checked=true 批量标记 / false 批量取消; * 仅处理本采购单内有订货明细的商品,重复标记幂等;与采购单状态无关) */ #[PutRoute(route: '/{id}/check', authorize: 'query', where: ['id' => '[0-9]+'])] public function batchItemCheck(int $id, Request $request): JsonResponse { $data = $request->validate([ 'product_ids' => 'required|array|min:1', 'product_ids.*' => 'integer', 'checked' => 'required|boolean', ], [ 'product_ids.required' => '请选择商品', 'product_ids.min' => '请选择商品', ]); $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } // 仅处理本采购单内有订货明细的商品(无效商品静默忽略) $productIds = StoreOrderItemModel::query() ->where('purchase_id', $purchase->id) ->whereIn('product_id', array_map('intval', $data['product_ids'])) ->distinct() ->pluck('product_id') ->map(static fn ($v) => (int) $v); if (! (bool) $data['checked']) { PurchaseItemCheckModel::query() ->where('purchase_id', $purchase->id) ->whereIn('product_id', $productIds) ->delete(); return $this->success(['count' => $productIds->count()], '已取消对账标记'); } // 批量标记:跳过已标记行,仅补插缺失行(幂等) $marked = PurchaseItemCheckModel::query() ->where('purchase_id', $purchase->id) ->whereIn('product_id', $productIds) ->pluck('product_id') ->map(static fn ($v) => (int) $v); $operatorId = (int) $request->user()->id; $now = now(); $rows = $productIds->diff($marked) ->map(static fn (int $productId) => [ 'purchase_id' => $purchase->id, 'product_id' => $productId, 'operator_id' => $operatorId, 'created_at' => $now, 'updated_at' => $now, ]) ->values() ->all(); if ($rows !== []) { PurchaseItemCheckModel::insert($rows); } return $this->success(['count' => $productIds->count()], '已标记为已对账'); } /** * 采购单编辑闸:仅进行中(待采购)允许修改明细 */ private function assertPurchaseEditable(PurchaseOrderModel $purchase): void { if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) { throw new RepositoryException('采购单已完成,不允许修改明细'); } } }