star-cloud/app/Services/Transaction/PharmacyPickupService.php
root 003ba9b1ad [FEAT] 新增領藥單(Pharmacy Pickup / Rx)功能並強化出貨防呆
1. 領藥單後台:新增 PharmacyPickupController 與 PharmacyPickupService,提供領藥單列表/建立/作廢/列印 QR;routes/web.php 新增受 menu.sales.pharmacy-pickup 權限控管的路由群組,RoleSeeder 補該權限、sidebar 新增選單。
2. 訂單模型:Order 新增 order_type(sale/pharmacy_pickup)、pricing_slip_no、created_by 欄位與 AWAITING_PICKUP 狀態,新增 sales/pharmacyPickup scope 及 creator/pickupCode 關聯。
3. 機台設定:系統設定新增「領藥單」開關,僅在取物單(pickup_sheet)模式下可啟用、其他模式強制關閉;含後端防呆、toggle UI 與啟用功能標籤顯示。
4. 機台 API:getSettings FunctionSet 增加 PharmacyPickup 旗標;B660 取貨核銷擴充領藥單多貨道出貨清單回傳,庫存不足/無可用貨道時嚴格擋關且不消耗領藥碼。
5. 出貨流程:TransactionService finalizeTransaction 以 order_no(RX) 對應預建領藥單,走 finalizePharmacyDispense 獨立冪等出貨流程,並標記領藥碼已領與核銷日誌。
6. 防呆修正:recordDispense 庫存<=0 時不再扣減以避免負庫存;syncSlots 上報清單為空時不執行全量刪除,避免誤清貨道。
7. 資料庫:新增 2 個 migration(orders 增領藥單欄位、pickup_codes 之 slot_no 改 nullable);PickupCode 開放 status 可填、isValid 容許 null expires_at。
8. 多語系/文件:zh_TW/en/ja 三語系對齊補齊領藥單相關詞彙(27 個);config/api-docs.php 補 PharmacyPickup 旗標說明。
2026-06-23 02:19:17 +00:00

305 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Transaction;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineSlot;
use App\Models\Product\Product;
use App\Models\System\SystemOperationLog;
use App\Models\Transaction\Order;
use App\Models\Transaction\PickupCode;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 領藥單建立服務。
*
* 設計重點(對齊交叉檢查修正):
* - 領藥單序號 = orders.flow_id唯一讓後續出貨 finalize 能套用既有終態冪等保護。
* - 建單時「不」預扣庫存;庫存僅在機台出貨 finalize 時扣一次(避免雙重扣減)。
* - 以「商品」為單位建單,貨道於 B660 掃碼時再由 product_id × machine_slots 解析(一張單可多貨道)。
*/
class PharmacyPickupService
{
/** 領藥單支付類型碼:沿用 42 = 領藥憑證 (Pickup Voucher)。 */
public const PAYMENT_TYPE_PHARMACY = 42;
/**
* 取得某機台「已生成但尚未領取(awaiting_pickup)」的領藥單預留量(依商品彙總)。
* 用於建單時「預扣」顯示與防超領:可用 = 實體庫存 預留量。
*/
public function reservedQtyMap(Machine $machine): array
{
$map = [];
$orders = Order::where('machine_id', $machine->id)
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
->where('status', Order::STATUS_AWAITING_PICKUP)
->with('items')
->get();
foreach ($orders as $order) {
foreach ($order->items as $item) {
$map[$item->product_id] = ($map[$item->product_id] ?? 0) + (int) $item->quantity;
}
}
return $map;
}
/**
* 取得某機台目前「有庫存」的可領藥品(依商品彙總,跨貨道加總庫存)。
* available 已扣除「尚未領取的領藥單預留量」(生成領藥單即預扣)。
*/
public function availableProducts(Machine $machine): Collection
{
$reserved = $this->reservedQtyMap($machine);
return MachineSlot::where('machine_id', $machine->id)
->where('is_active', true)
->where('stock', '>', 0)
->with('product')
->get()
->groupBy('product_id')
->map(function ($slots) use ($reserved) {
$product = $slots->first()->product;
if (!$product) {
return null;
}
$physical = (int) $slots->sum('stock');
$reservedQty = (int) ($reserved[$product->id] ?? 0);
$available = max(0, $physical - $reservedQty);
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' => $available, // 預扣後可用(= 實體 預留)
'physical' => $physical, // 實體庫存
'reserved' => $reservedQty, // 已生成領藥單預留量
'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<array{product_id:int, quantity:int}>, 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;
// 既有「尚未領取領藥單」的預留量(本單尚未建立,不含本單)→ 可用 = 實體 預留,防超領。
$reserved = $this->reservedQtyMap($machine);
foreach ($items as $item) {
$product = Product::findOrFail($item['product_id']);
$qty = $item['quantity'];
// 該機台此商品跨貨道可用庫存 = 實體庫存 已生成領藥單預留量
$physical = (int) MachineSlot::where('machine_id', $machine->id)
->where('product_id', $product->id)
->where('is_active', true)
->sum('stock');
$available = $physical - (int) ($reserved[$product->id] ?? 0);
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']);
});
}
/**
* 檢查一張領藥單在「目前機台貨道庫存」下是否有商品庫存不足(無法整單出貨)。
* 回傳不足清單:[['product_name'=>, 'required'=>, 'available'=>], ...];空陣列表示可整單出貨。
* 用於 B660 掃碼出貨前的嚴格擋關:任何商品不足即整單擋下、不出貨、不消耗領藥碼。
*/
public function dispenseShortage(Order $order): array
{
$short = [];
foreach ($order->items as $orderItem) {
$available = (int) MachineSlot::where('machine_id', $order->machine_id)
->where('product_id', $orderItem->product_id)
->where('is_active', true)
->where('stock', '>', 0)
->sum('stock');
if ($available < (int) $orderItem->quantity) {
$short[] = [
'product_name' => $orderItem->product_name,
'required' => (int) $orderItem->quantity,
'available' => $available,
];
}
}
return $short;
}
/**
* 解析一張領藥單在「目前機台貨道庫存」下的出貨清單(一商品可跨多貨道湊量)。
* 回傳:[['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;
}
}