id) ->where('is_active', true) ->where('stock', '>', 0) ->with('product') ->get() ->groupBy('product_id') ->map(function ($slots) { $product = $slots->first()->product; if (!$product) { return null; } return (object) [ 'product_id' => $product->id, 'name' => $product->localized_name ?? $product->name, 'spec' => $product->localized_spec ?? $product->spec, 'barcode' => $product->barcode, 'price' => (float) ($product->price ?? 0), 'available' => (int) $slots->sum('stock'), 'slot_count' => $slots->count(), 'slots' => $slots->pluck('slot_no')->implode(', '), ]; }) ->filter() ->sortBy('name') ->values(); } /** * 建立一張領藥單:Order(pharmacy_pickup) + N 個 OrderItem + 1 個 PickupCode。 * * @param array{machine_id:int, pricing_slip_no?:string, items:array, expires_at?:\DateTimeInterface} $data */ public function create(array $data, int $userId): Order { $machine = Machine::findOrFail($data['machine_id']); $items = collect($data['items'] ?? []) ->map(fn ($i) => ['product_id' => (int) $i['product_id'], 'quantity' => (int) $i['quantity']]) ->filter(fn ($i) => $i['quantity'] > 0) ->values(); if ($items->isEmpty()) { throw ValidationException::withMessages([ 'items' => __('Please select at least one medicine with quantity.'), ]); } return DB::transaction(function () use ($machine, $items, $data, $userId) { $orderItemsData = []; $total = 0.0; foreach ($items as $item) { $product = Product::findOrFail($item['product_id']); $qty = $item['quantity']; // 該機台此商品跨貨道可用庫存(僅檢查,不預扣) $available = (int) MachineSlot::where('machine_id', $machine->id) ->where('product_id', $product->id) ->where('is_active', true) ->sum('stock'); if ($qty > $available) { throw ValidationException::withMessages([ 'items' => __('Insufficient stock for :name (requested :qty, available :available).', [ 'name' => $product->localized_name ?? $product->name, 'qty' => $qty, 'available' => $available, ]), ]); } $price = (float) ($product->price ?? 0); $subtotal = $price * $qty; $total += $subtotal; $orderItemsData[] = [ 'product_id' => $product->id, 'product_name' => $product->localized_name ?? $product->name, 'barcode' => $product->barcode, 'quantity' => $qty, 'price' => $price, 'subtotal' => $subtotal, ]; } $serial = $this->generateSerial(); $order = Order::create([ 'company_id' => $machine->company_id, 'flow_id' => $serial, 'order_no' => $serial, 'order_type' => Order::TYPE_PHARMACY_PICKUP, 'pricing_slip_no' => $data['pricing_slip_no'] ?? null, 'created_by' => $userId, 'machine_id' => $machine->id, 'payment_type' => self::PAYMENT_TYPE_PHARMACY, 'total_amount' => $total, 'discount_amount' => 0, 'pay_amount' => 0, 'change_amount' => 0, 'points_used' => 0, 'payment_status' => Order::PAYMENT_STATUS_SUCCESS, 'status' => Order::STATUS_AWAITING_PICKUP, 'delivery_status' => Order::DELIVERY_STATUS_FAILED, // 0:尚未出貨 'machine_time' => now(), ]); foreach ($orderItemsData as $oi) { $order->items()->create($oi); } $pickupCode = PickupCode::create([ 'company_id' => $machine->company_id, 'machine_id' => $machine->id, 'slot_no' => null, // 多貨道:由 B660 掃碼時解析 'code' => PickupCode::generateUniqueCode($machine->id), 'status' => 'active', 'expires_at' => $data['expires_at'] ?? now()->endOfDay(), // 預設當日有效 'usage_limit' => 1, 'usage_count' => 0, 'created_by' => $userId, 'order_id' => $order->id, ]); SystemOperationLog::create([ 'company_id' => $machine->company_id, 'user_id' => $userId, 'module' => 'pharmacy_pickup', 'action' => 'create', 'target_id' => $order->id, 'target_type' => Order::class, 'new_values' => [ 'order_no' => $order->order_no, 'pricing_slip_no' => $order->pricing_slip_no, 'pickup_code' => $pickupCode->code, 'items' => $orderItemsData, ], ]); return $order->load(['items', 'pickupCode', 'machine']); }); } /** * 解析一張領藥單在「目前機台貨道庫存」下的出貨清單(一商品可跨多貨道湊量)。 * 回傳:[['slot_no'=>, 'product_id'=>, 'product_name'=>, 'qty'=>], ...] * 庫存不足時盡力分配(出可出的數量;部分/失敗由出貨回報 Phase 標記)。 */ public function resolveDispenseItems(Order $order): array { $result = []; foreach ($order->items as $orderItem) { $remaining = (int) $orderItem->quantity; $slots = MachineSlot::where('machine_id', $order->machine_id) ->where('product_id', $orderItem->product_id) ->where('is_active', true) ->where('stock', '>', 0) ->orderByDesc('stock') ->orderBy('slot_no') ->get(); foreach ($slots as $slot) { if ($remaining <= 0) { break; } $take = min($remaining, (int) $slot->stock); if ($take <= 0) { continue; } $result[] = [ 'slot_no' => $slot->slot_no, 'product_id' => $orderItem->product_id, 'product_name' => $orderItem->product_name, 'qty' => $take, ]; $remaining -= $take; } // $remaining > 0 表示目前庫存不足以完全滿足此商品,僅回傳可出的部分。 } return $result; } /** * 產生領藥單序號:RX + 西元年月日 + 當日 4 碼流水(與 flow_id 唯一性衝突時自動遞增)。 */ protected function generateSerial(): string { $datePart = now()->format('Ymd'); $base = Order::withoutGlobalScopes() ->where('order_type', Order::TYPE_PHARMACY_PICKUP) ->whereDate('created_at', now()->toDateString()) ->count(); $attempt = 0; do { $seq = $base + 1 + $attempt; $serial = 'RX' . $datePart . str_pad((string) $seq, 4, '0', STR_PAD_LEFT); $attempt++; } while ( Order::withoutGlobalScopes()->where('flow_id', $serial)->exists() && $attempt < 100 ); return $serial; } }