Compare commits
1 Commits
main
...
feat/pharm
| Author | SHA1 | Date | |
|---|---|---|---|
| 8977e7eb0f |
61
PHARMACY_PICKUP_DESIGN.md
Normal file
61
PHARMACY_PICKUP_DESIGN.md
Normal file
@ -0,0 +1,61 @@
|
||||
# 領藥單 / 批價單取藥模組 — 設計文件(v2,已納入交叉檢查修正)
|
||||
|
||||
分支:`feat/pharmacy-pickup`(自 `dev` 切出)。
|
||||
|
||||
`star-cloud` 是自動販賣機(智能機)雲端後台(Laravel 12 + Sail + MySQL + Redis + EMQX + Go mqtt-gateway)。
|
||||
本模組為**可授權功能**,只開放給部分客戶/機台:藥師依病人「批價單」人工建立「領藥單」→ 系統產序號 + 一張 QR → 病人到智能機掃碼 → 雲端驗證回多貨道 → 一次出全部 → 出貨結果回傳後台。不串 HIS、不存病人姓名病歷(比照中國醫取物單僅存編號)。
|
||||
|
||||
---
|
||||
|
||||
## 一、目標流程
|
||||
1. 病人拿批價單到櫃台。
|
||||
2. 藥師在後台**人工**勾選該機台貨道上的藥品 + 數量,建立「領藥單」;可選填**批價單號**當依據。
|
||||
3. 領藥單序號系統自動產生(`RX` + 日期 + 流水,如 `RX26061800001`),並**同時作為 `orders.flow_id`**(見修正②)。
|
||||
4. 系統產生**一張 QR 領藥單**,列印給病人。
|
||||
5. 病人到智能機掃 QR → 機台呼叫 **B660** → 雲端確認該機台為 `pickup_sheet` 模式且 `pharmacy_pickup_enabled` 開啟 → 回傳該單**所有貨道清單** → 機台一次出全部。
|
||||
6. 出貨結果經 MQTT `transaction/finalize`(帶 `dispense[]`)回傳 → 彙整更新領藥單出貨狀態。
|
||||
|
||||
---
|
||||
|
||||
## 二、模組授權機制(定案:取物單模式下的開關)
|
||||
領藥單**不是**獨立的 shopping_mode,而是既有「取物單(pickup_sheet)」模式**底下的一個開關** `pharmacy_pickup_enabled`:
|
||||
- `machines.settings.shopping_mode` 維持三種:`basic` / `employee_card` / `pickup_sheet`(取物單,中國醫物料編號領藥)。
|
||||
- 在「機台系統設定 → 購物方式」選 `pickup_sheet` 時,下方出現「**領藥單**」開關 `settings.pharmacy_pickup_enabled`。
|
||||
- 授權 = 機台為 `pickup_sheet` 模式且 `pharmacy_pickup_enabled=true`;後台「領藥單」選單僅在「公司有任一機台符合此條件」時顯示。
|
||||
- B014 `getSettings` 的 `FunctionSet` 新增 `PharmacyPickup` 旗標下發給機台;`ShoppingMode` 仍為 pickup_sheet。
|
||||
- 控制器強制:非 pickup_sheet 模式時 `pharmacy_pickup_enabled` 一律存 false。
|
||||
- (後續可選)公司層級 license gate,由系統管理員授權哪些公司可開此開關。
|
||||
|
||||
### ✅ Phase 1 已完成(本次)
|
||||
- `machines/index.blade.php`:選「取物單」時,下方顯示「領藥單」開關(checkbox `settings[pharmacy_pickup_enabled]`)+ Alpine 資料。
|
||||
- `partials/tab-system-settings.blade.php`:取物單模式且開關開時,摘要加顯示 `領藥單`。
|
||||
- `MachineSettingController.php`:`shopping_mode` 白名單(basic/employee_card/pickup_sheet);新增持久化 `pharmacy_pickup_enabled`(僅 pickup_sheet 模式可為 true)。
|
||||
- `MachineController.php`(B014):`FunctionSet.PharmacyPickup` 下發。
|
||||
- `lang/{en,zh_TW,ja}.json`:新增 `Pharmacy Pickup (Rx)`(zh_TW=「領藥單」)+ 開關說明。
|
||||
- 驗證:2 controller PHP lint 通過、view:cache 成功、3 JSON 合法、登入頁 200。已部署測試機 192.168.0.130。
|
||||
|
||||
---
|
||||
|
||||
## 三、交叉檢查修正(必須遵守)
|
||||
- **修正①(旗標)**:領藥單做成「取物單(pickup_sheet)模式下的開關 `pharmacy_pickup_enabled`」,並完整接上 B014 `FunctionSet.PharmacyPickup` 下發(非孤立旗標)。✅ 已採用。
|
||||
- **修正②(冪等/扣庫存)**:領藥單必須有 `flow_id`(用領藥單序號),讓出貨上報套用既有 `finalizeTransaction` 終態冪等保護;**庫存只在 finalize 扣一次**,建單不預扣(避免雙重扣減 / 重送 MQTT 重複扣)。
|
||||
- **修正③(B660 相容)**:保留回傳 `slot_no`(領藥單填第一筆或 null),**additive 新增 `items:[{slot_no,product_name,qty}]`**;用 `order_type` 分流;靠 `shopping_mode=pharmacy_pickup` gate 只放行新版機台。
|
||||
- **修正④(報表/發票隔離)**:`orders` 加 `order_type`;`SalesController` 銷售分頁與 CSV 匯出預設 `where order_type='sale'`,領藥單獨立頁;確認領藥 finalize 上報**不帶 invoice 區塊**(不會誤開發票)。
|
||||
- **修正⑤(既有脆弱點)**:先修 `PickupCode::isValid()` 對 null `expires_at` 的 NPE(`$this->expires_at?->isFuture() ?? true`);把 `status` 補進 PickupCode `$fillable`;確認 `order_items` 實際欄位(`sku` vs `barcode`)。
|
||||
- **修正⑥(建單必填)**:`orders.payment_status` / `payment_type` 為 NOT NULL,建單須明確給值(領藥單給 0 / 特定碼);多貨道「一次出全部」需與機台端對齊(既有 B055/dispatchDispense 為單貨道)。
|
||||
|
||||
---
|
||||
|
||||
## 四、資料模型變更
|
||||
- `orders` 新增:`order_type ENUM('sale','pharmacy_pickup') default 'sale'`、`pricing_slip_no VARCHAR NULL`、`created_by BIGINT NULL`。序號存 `order_no` 且**等於 `flow_id`**。
|
||||
- `pickup_codes.slot_no` 改 nullable(領藥單多貨道由 order_items×machine_slots 解析;一般取貨碼仍走 slot_no,以 order_type 區分讀取)。
|
||||
- 一張領藥單 = 1 Order(`pharmacy_pickup`) + N OrderItem + 1 PickupCode;付款欄位給定值;`delivery_status` 初始 0。
|
||||
|
||||
---
|
||||
|
||||
## 五、後續任務(修正後)
|
||||
- **Phase 2 資料模型**:上述 migration;Order 加常數/scope;先修正⑤的 PickupCode 脆弱點。
|
||||
- **Phase 3 後台建單 + 列印**:`PharmacyPickupService::create()`(產序號=flow_id、庫存檢查、交易內建單)、`Admin/PharmacyPickupController`、勾選頁 + 列印頁(QR、不顯示姓名)。建單僅限 `pharmacy_pickup` 模式機台。
|
||||
- **Phase 4 B660**:additive items[] + order_type 分流 + 模式 gate + 標記碼已領。
|
||||
- **Phase 5 回報**:finalize 帶 dispense[] 一次彙整(復用 `resolveDeliveryStatusFromDispense`),靠 flow_id 終態冪等;庫存只扣一次;領藥單詳情顯示逐道結果。
|
||||
- **Phase 6 Android(另 repo)**:`shopping_mode=pharmacy_pickup` 啟用領藥 SaleFlow(沿用中國醫 TakeMedicineDialog/DispatchDialog,資料來源換 B660)。
|
||||
@ -364,7 +364,11 @@ class MachineSettingController extends AdminController
|
||||
'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
|
||||
$allowedShoppingModes = ['basic', 'employee_card', 'pickup_sheet'];
|
||||
$shoppingMode = $settings['shopping_mode'] ?? 'basic';
|
||||
if (!in_array($shoppingMode, $allowedShoppingModes, true)) {
|
||||
$shoppingMode = 'basic';
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
@ -434,6 +438,11 @@ class MachineSettingController extends AdminController
|
||||
$jsonSettings[$field] = $data[$field];
|
||||
}
|
||||
|
||||
// 領藥單模組:僅在「取物單(pickup_sheet)」模式下可啟用,其他模式一律關閉
|
||||
$jsonSettings['pharmacy_pickup_enabled'] = ($shoppingMode === 'pickup_sheet')
|
||||
? (bool) ($settings['pharmacy_pickup_enabled'] ?? false)
|
||||
: false;
|
||||
|
||||
// 顯示語系 (languages):機台螢幕可切換的語系,最多 N 種,僅系統管理員可設定。
|
||||
// 商品翻譯範圍由「公司機台語系聯集」反推,故此處異動需失效聯集快取並重建商品目錄。
|
||||
// 非系統管理員即使送出 languages 亦一律忽略(不報錯,讓其仍可儲存其他設定)。
|
||||
|
||||
140
app/Http/Controllers/Admin/PharmacyPickupController.php
Normal file
140
app/Http/Controllers/Admin/PharmacyPickupController.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Services\Transaction\PharmacyPickupService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
/**
|
||||
* 領藥單管理(銷售管理 → 領藥單)。
|
||||
*
|
||||
* 授權雙閘:
|
||||
* 1. 選單/頁面:權限 menu.sales.pharmacy-pickup(角色/帳號權限可勾選)。
|
||||
* 2. 機台行為:機台系統設定 shopping_mode=pickup_sheet 且 pharmacy_pickup_enabled=true。
|
||||
*/
|
||||
class PharmacyPickupController extends AdminController
|
||||
{
|
||||
public function __construct(private PharmacyPickupService $service)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 僅列出「取物單模式 + 領藥單開關開啟」的機台(受 TenantScoped 自動租戶隔離)
|
||||
$machines = Machine::query()
|
||||
->where('settings->shopping_mode', 'pickup_sheet')
|
||||
->where('settings->pharmacy_pickup_enabled', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$selectedMachine = null;
|
||||
$products = new Collection();
|
||||
|
||||
if ($request->filled('machine_id')) {
|
||||
$selectedMachine = $machines->firstWhere('id', (int) $request->input('machine_id'));
|
||||
if ($selectedMachine) {
|
||||
$products = $this->service->availableProducts($selectedMachine);
|
||||
}
|
||||
}
|
||||
|
||||
$orders = Order::pharmacyPickup()
|
||||
->with(['machine:id,name,serial_no', 'items', 'creator:id,name', 'pickupCode'])
|
||||
->latest()
|
||||
->paginate($request->input('per_page', 10))
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.sales.pharmacy-pickup.index', [
|
||||
'title' => __('menu.sales.pharmacy-pickup'),
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
'products' => $products,
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'pricing_slip_no' => 'nullable|string|max:64',
|
||||
]);
|
||||
|
||||
// 由勾選的 products[] + qty[product_id] 組出 items
|
||||
$productIds = (array) $request->input('products', []);
|
||||
$qtyMap = (array) $request->input('qty', []);
|
||||
$items = [];
|
||||
foreach ($productIds as $pid) {
|
||||
$q = (int) ($qtyMap[$pid] ?? 0);
|
||||
if ($q > 0) {
|
||||
$items[] = ['product_id' => (int) $pid, 'quantity' => $q];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$order = $this->service->create([
|
||||
'machine_id' => (int) $request->input('machine_id'),
|
||||
'pricing_slip_no' => $request->input('pricing_slip_no'),
|
||||
'items' => $items,
|
||||
], Auth::id());
|
||||
} catch (ValidationException $e) {
|
||||
return back()->withErrors($e->errors())->withInput();
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.sales.pharmacy-pickup', ['machine_id' => $request->input('machine_id')])
|
||||
->with('success', __('Pharmacy pickup order created: :no', ['no' => $order->order_no]))
|
||||
->with('created_order_id', $order->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可列印的領藥單(QR 內嵌 SVG,內容為 8 碼領藥碼,機台掃描後送 B660 驗證)。
|
||||
*/
|
||||
public function print(Order $order)
|
||||
{
|
||||
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
|
||||
|
||||
$order->load(['items', 'machine', 'pickupCode', 'creator:id,name']);
|
||||
$code = $order->pickupCode?->code;
|
||||
$qrSvg = $code
|
||||
? QrCode::format('svg')->size(220)->margin(1)->errorCorrection('M')->generate($code)
|
||||
: null;
|
||||
|
||||
return view('admin.sales.pharmacy-pickup.print', [
|
||||
'order' => $order,
|
||||
'qrSvg' => $qrSvg,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Order $order)
|
||||
{
|
||||
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
|
||||
|
||||
if (in_array($order->status, [Order::STATUS_COMPLETED, Order::STATUS_FAILED], true)
|
||||
|| $order->delivery_status === Order::DELIVERY_STATUS_SUCCESS) {
|
||||
return back()->with('error', __('This pharmacy pickup order can no longer be cancelled.'));
|
||||
}
|
||||
|
||||
$order->update(['status' => Order::STATUS_FAILED]);
|
||||
if ($order->pickupCode) {
|
||||
$order->pickupCode->update(['status' => 'cancelled']);
|
||||
}
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
'user_id' => Auth::id(),
|
||||
'module' => 'pharmacy_pickup',
|
||||
'action' => 'cancel',
|
||||
'target_id' => $order->id,
|
||||
'target_type' => Order::class,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Pharmacy pickup order cancelled.'));
|
||||
}
|
||||
}
|
||||
@ -501,6 +501,7 @@ class MachineController extends Controller
|
||||
'WelcomeGift' => (bool) ($s['welcome_gift_enabled'] ?? false), // 來店禮
|
||||
'MemberSystem' => (bool) ($s['member_system_enabled'] ?? false), // 會員系統
|
||||
'AmbientTemp' => (bool) ($s['ambient_temp_monitoring_enabled'] ?? false), // 環境溫度監控
|
||||
'PharmacyPickup' => (bool) ($s['pharmacy_pickup_enabled'] ?? false), // 領藥單(雲端建單,取物單模式下開關)
|
||||
];
|
||||
|
||||
// 5-4 ShoppingMode:購物方式(頂層字串)
|
||||
@ -690,17 +691,50 @@ class MachineController extends Controller
|
||||
]);
|
||||
|
||||
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
|
||||
// 憑證的「核銷」將由 MQTT finalizeTransaction 流程處理,不再需要 B660 PUT
|
||||
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
|
||||
|
||||
$data = [
|
||||
'slot_no' => $pickupCode->slot_no, // 保留:既有單貨道機台讀此欄(領藥單為 null)
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'code_id' => $pickupCode->id, // 統一回傳 code_id
|
||||
'status' => $pickupCode->status,
|
||||
];
|
||||
|
||||
// === 領藥單(pharmacy_pickup)擴充:回傳多貨道清單 + 標記碼已領(僅領藥單,不影響既有單貨道流程) ===
|
||||
$order = $pickupCode->order;
|
||||
$isPharmacy = $order && $order->order_type === \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
|
||||
|
||||
if ($isPharmacy) {
|
||||
// Gate:機台須仍為「取物單模式 + 領藥單開關開啟」才放行領藥碼
|
||||
$settings = $machine->settings ?? [];
|
||||
$enabled = (($settings['shopping_mode'] ?? null) === 'pickup_sheet')
|
||||
&& (bool) ($settings['pharmacy_pickup_enabled'] ?? false);
|
||||
|
||||
if (!$enabled) {
|
||||
return response()->json(['success' => false, 'message' => 'Pharmacy pickup not enabled on this machine', 'code' => 403], 403);
|
||||
}
|
||||
|
||||
// 依目前貨道庫存解析多貨道出貨清單(一商品可跨多貨道湊量)
|
||||
$items = app(\App\Services\Transaction\PharmacyPickupService::class)->resolveDispenseItems($order);
|
||||
|
||||
// 標記碼已領(usage_count++;達上限轉 used)——僅領藥單,避免動到既有單貨道核銷流程
|
||||
$pickupCode->usage_count = (int) $pickupCode->usage_count + 1;
|
||||
$pickupCode->used_at = $pickupCode->used_at ?? now();
|
||||
if ($pickupCode->usage_count >= (int) $pickupCode->usage_limit) {
|
||||
$pickupCode->status = 'used';
|
||||
}
|
||||
$pickupCode->save();
|
||||
|
||||
$data['order_type'] = \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
|
||||
$data['order_no'] = $order->order_no;
|
||||
$data['items'] = $items; // [{slot_no, product_id, product_name, qty}, ...]
|
||||
$data['status'] = $pickupCode->status;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'slot_no' => $pickupCode->slot_no,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'code_id' => $pickupCode->id, // 統一回傳 code_id
|
||||
'status' => 'active'
|
||||
]
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,10 @@ class Order extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
// 訂單類型 (order_type)
|
||||
public const TYPE_SALE = 'sale'; // 一般銷售
|
||||
public const TYPE_PHARMACY_PICKUP = 'pharmacy_pickup'; // 領藥單
|
||||
|
||||
// 支付狀態
|
||||
public const PAYMENT_STATUS_FAILED = 0;
|
||||
public const PAYMENT_STATUS_SUCCESS = 1;
|
||||
@ -30,6 +34,8 @@ class Order extends Model
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
// 領藥單專用:已開立、等待病人到機台領取(非 pending,不被逾時清掃;非終態,可被出貨 finalize 更新)。
|
||||
public const STATUS_AWAITING_PICKUP = 'awaiting_pickup';
|
||||
|
||||
/** 終態清單:到了這些狀態就不再變更(冪等)。 */
|
||||
public const TERMINAL_STATUSES = [
|
||||
@ -42,6 +48,9 @@ class Order extends Model
|
||||
'company_id',
|
||||
'flow_id',
|
||||
'order_no',
|
||||
'order_type',
|
||||
'pricing_slip_no',
|
||||
'created_by',
|
||||
'machine_id',
|
||||
'member_id',
|
||||
'payment_type',
|
||||
@ -182,11 +191,29 @@ class Order extends Model
|
||||
. mb_substr($name, -1);
|
||||
}
|
||||
|
||||
/** 一般銷售訂單 */
|
||||
public function scopeSales($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_SALE);
|
||||
}
|
||||
|
||||
/** 領藥單 */
|
||||
public function scopePharmacyPickup($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_PHARMACY_PICKUP);
|
||||
}
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/** 建單者 (領藥單藥師) */
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
@ -207,6 +234,12 @@ class Order extends Model
|
||||
return $this->hasMany(DispenseRecord::class);
|
||||
}
|
||||
|
||||
/** 領藥單對應的領藥碼(一單一碼) */
|
||||
public function pickupCode()
|
||||
{
|
||||
return $this->hasOne(PickupCode::class);
|
||||
}
|
||||
|
||||
public function staffCardLog()
|
||||
{
|
||||
return $this->hasOne(\App\Models\StaffCardLog::class);
|
||||
|
||||
@ -18,6 +18,7 @@ class PickupCode extends Model
|
||||
'slot_no',
|
||||
'code',
|
||||
'slug',
|
||||
'status',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'usage_limit',
|
||||
@ -80,7 +81,7 @@ class PickupCode extends Model
|
||||
{
|
||||
return $this->status === 'active'
|
||||
&& ($this->usage_count < $this->usage_limit)
|
||||
&& ($this->expires_at->isFuture());
|
||||
&& ($this->expires_at?->isFuture() ?? true); // null expires_at = 無到期限制
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
241
app/Services/Transaction/PharmacyPickupService.php
Normal file
241
app/Services/Transaction/PharmacyPickupService.php
Normal file
@ -0,0 +1,241 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 取得某機台目前「有庫存」的可領藥品(依商品彙總,跨貨道加總庫存)。
|
||||
*/
|
||||
public function availableProducts(Machine $machine): Collection
|
||||
{
|
||||
return MachineSlot::where('machine_id', $machine->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<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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -348,6 +348,12 @@ class TransactionService
|
||||
]);
|
||||
return $existingOrder;
|
||||
}
|
||||
|
||||
// 領藥單(pharmacy_pickup):走獨立出貨回報流程,完全不經銷售/發票/閉環邏輯。
|
||||
// 第一次回報後 status→completed(終態),後續重送會在上面的終態檢查 early-return(冪等、不重複扣庫存)。
|
||||
if ($existingOrder && $existingOrder->order_type === Order::TYPE_PHARMACY_PICKUP) {
|
||||
return $this->finalizePharmacyDispense($existingOrder, $data);
|
||||
}
|
||||
} else {
|
||||
throw new \Exception("Flow ID is required for finalization");
|
||||
}
|
||||
@ -517,6 +523,67 @@ class TransactionService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 領藥單出貨回報(獨立於銷售流程)。
|
||||
*
|
||||
* 機台逐道出貨後,以 transaction/finalize 帶 dispense[](每出貨一單位一筆,含 slot_no/dispense_status)回報。
|
||||
* 本方法:建立出貨紀錄(沿用 recordDispense:成功則扣 1 庫存)→ 彙整 delivery_status →
|
||||
* 將訂單轉為終態 completed(冪等:重送會在 finalizeTransaction 開頭 early-return)→ 標記領藥碼已領 + 核銷日誌。
|
||||
*/
|
||||
protected function finalizePharmacyDispense(Order $order, array $data): Order
|
||||
{
|
||||
return DB::transaction(function () use ($order, $data) {
|
||||
$serialNo = $data['serial_no'] ?? $order->machine?->serial_no;
|
||||
|
||||
if (!empty($data['dispense'])) {
|
||||
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
|
||||
foreach ($dispenseList as $dispenseItem) {
|
||||
$dispenseItem['serial_no'] = $serialNo;
|
||||
$dispenseItem['flow_id'] = $order->flow_id;
|
||||
$dispenseItem['order_id'] = $order->id;
|
||||
$this->recordDispense($dispenseItem); // 建 DispenseRecord;dispense_status=1 時扣 1 庫存
|
||||
}
|
||||
$deliveryStatus = $this->resolveDeliveryStatusFromDispense($data['dispense']);
|
||||
} else {
|
||||
// 無出貨明細視為失敗
|
||||
$deliveryStatus = Order::DELIVERY_STATUS_FAILED;
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'delivery_status' => $deliveryStatus,
|
||||
'status' => Order::STATUS_COMPLETED, // 終態 → 後續重送冪等
|
||||
'machine_time' => $data['machine_time'] ?? $order->machine_time,
|
||||
]);
|
||||
|
||||
// 標記領藥碼已領(B660 通常已標 used;此處保險並留核銷日誌)
|
||||
$pickupCode = $order->pickupCode;
|
||||
if ($pickupCode) {
|
||||
if ($pickupCode->status !== 'used') {
|
||||
$pickupCode->update(['status' => 'used', 'used_at' => $pickupCode->used_at ?? now()]);
|
||||
}
|
||||
PickupCodeLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
'machine_id' => $order->machine_id,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'order_id' => $order->id,
|
||||
'action' => 'used',
|
||||
'remark' => "log.pickup.pharmacy_dispensed",
|
||||
'raw_data' => [
|
||||
'order_no' => $order->order_no,
|
||||
'delivery_status' => $deliveryStatus,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('Pharmacy pickup dispense finalized', [
|
||||
'flow_id' => $order->flow_id,
|
||||
'delivery_status' => $deliveryStatus,
|
||||
]);
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve order-level delivery status from item-level dispense results.
|
||||
*/
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 領藥單模組:orders 增加訂單類型/批價單號/建單者欄位。
|
||||
* order_type 預設 sale,既有資料一律視為一般銷售,不影響線上行為。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('orders', 'order_type')) {
|
||||
$table->string('order_type', 20)->default('sale')->index()
|
||||
->comment('訂單類型: sale=一般銷售, pharmacy_pickup=領藥單')->after('order_no');
|
||||
}
|
||||
if (!Schema::hasColumn('orders', 'pricing_slip_no')) {
|
||||
$table->string('pricing_slip_no', 64)->nullable()
|
||||
->comment('批價單號 (領藥單藥師填寫之依據)')->after('order_type');
|
||||
}
|
||||
if (!Schema::hasColumn('orders', 'created_by')) {
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()
|
||||
->comment('建單者 (領藥單由後台藥師建立)')->after('pricing_slip_no');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('orders', 'created_by')) {
|
||||
$table->dropForeign(['created_by']);
|
||||
$table->dropColumn('created_by');
|
||||
}
|
||||
if (Schema::hasColumn('orders', 'pricing_slip_no')) {
|
||||
$table->dropColumn('pricing_slip_no');
|
||||
}
|
||||
if (Schema::hasColumn('orders', 'order_type')) {
|
||||
$table->dropColumn('order_type');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 領藥單一張單多貨道:slot_no 改為可空。
|
||||
* 一般取貨碼仍寫入 slot_no(單貨道);領藥單的多貨道由 order_items × machine_slots 解析,
|
||||
* 故 slot_no 允許為 null,讀取端依 order_type 區分。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
$table->string('slot_no')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
$table->string('slot_no')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -39,6 +39,7 @@ class RoleSeeder extends Seeder
|
||||
'menu.sales.promotions',
|
||||
'menu.sales.pass-codes',
|
||||
'menu.sales.store-gifts',
|
||||
'menu.sales.pharmacy-pickup',
|
||||
'menu.analysis',
|
||||
'menu.analysis.change-stock',
|
||||
'menu.analysis.machine-reports',
|
||||
|
||||
@ -1549,6 +1549,8 @@
|
||||
"Pickup Recipient (Prescription Slip)": "Pickup Recipient (Prescription Slip)",
|
||||
"Pickup Recipient Info": "Pickup Recipient Info",
|
||||
"Pickup Sheet (Material No.)": "Pickup Sheet (Material No.)",
|
||||
"Pharmacy Pickup (Rx)": "Pharmacy Pickup (Rx)",
|
||||
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "Issue pharmacy pickup orders from the backend; patient scans QR to dispense.",
|
||||
"Pickup Ticket": "Pickup Ticket",
|
||||
"Pickup Voucher": "Pickup Voucher",
|
||||
"Pickup code cancelled.": "Pickup code cancelled.",
|
||||
@ -2532,6 +2534,7 @@
|
||||
"menu.sales.promotions": "Promotions",
|
||||
"menu.sales.records": "Sales Records",
|
||||
"menu.sales.store-gifts": "Store Gifts",
|
||||
"menu.sales.pharmacy-pickup": "Pharmacy Pickup",
|
||||
"menu.special-permission": "Special Permissions",
|
||||
"menu.special-permission.clear-stock": "Clear Stock",
|
||||
"menu.warehouses": "Warehouse Management",
|
||||
|
||||
@ -1549,6 +1549,8 @@
|
||||
"Pickup Recipient (Prescription Slip)": "受取者(処方箋)",
|
||||
"Pickup Recipient Info": "受取者情報",
|
||||
"Pickup Sheet (Material No.)": "ピッキングシート(材料番号)",
|
||||
"Pharmacy Pickup (Rx)": "薬品受取",
|
||||
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "バックエンドで薬品受取注文を発行し、患者がQRをスキャンして払い出します。",
|
||||
"Pickup Ticket": "商品受取チケット",
|
||||
"Pickup Voucher": "受取票",
|
||||
"Pickup code cancelled.": "受取コードがキャンセルされました。",
|
||||
@ -2532,6 +2534,7 @@
|
||||
"menu.sales.promotions": "プロモーション期間",
|
||||
"menu.sales.records": "販売記録",
|
||||
"menu.sales.store-gifts": "来店ギフト",
|
||||
"menu.sales.pharmacy-pickup": "薬品受取",
|
||||
"menu.special-permission": "特別権限",
|
||||
"menu.special-permission.clear-stock": "在庫クリア",
|
||||
"menu.warehouses": "倉庫管理",
|
||||
|
||||
@ -1549,6 +1549,8 @@
|
||||
"Pickup Recipient (Prescription Slip)": "取物人(領藥單)",
|
||||
"Pickup Recipient Info": "取物人資訊",
|
||||
"Pickup Sheet (Material No.)": "取物單(物料編號)",
|
||||
"Pharmacy Pickup (Rx)": "領藥單",
|
||||
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "由後台開立領藥單,病人到機台掃 QR 出貨。",
|
||||
"Pickup Ticket": "商品領取券",
|
||||
"Pickup Voucher": "取物單",
|
||||
"Pickup code cancelled.": "取貨碼已取消",
|
||||
@ -2532,6 +2534,32 @@
|
||||
"menu.sales.promotions": "促銷時段",
|
||||
"menu.sales.records": "銷售紀錄",
|
||||
"menu.sales.store-gifts": "來店禮",
|
||||
"menu.sales.pharmacy-pickup": "領藥單",
|
||||
"Spec": "規格",
|
||||
"Created": "建立時間",
|
||||
"Available": "可用庫存",
|
||||
"optional": "選填",
|
||||
"Awaiting Pickup": "待領取",
|
||||
"Picked Up": "已領取",
|
||||
"Voided": "已作廢",
|
||||
"Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.": "開立領藥單、列印 QR 領藥單,病人到機台掃碼即可出貨。",
|
||||
"Create Pharmacy Pickup Order": "建立領藥單",
|
||||
"No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).": "尚無啟用領藥單的機台。請至「機台系統設定」(取物單模式 → 領藥單開關)啟用。",
|
||||
"-- Select Machine --": "-- 選擇機台 --",
|
||||
"Pricing Slip No.": "批價單號",
|
||||
"e.g. the hospital pricing slip number": "例如:醫院批價單上的單號",
|
||||
"This machine currently has no medicine in stock.": "此機台目前無可領藥品庫存。",
|
||||
"Medicine": "藥品",
|
||||
"Create & Generate QR": "建立並產生 QR",
|
||||
"Pharmacy Pickup Orders": "領藥單列表",
|
||||
"Cancel this pharmacy pickup order?": "確定要作廢這張領藥單嗎?",
|
||||
"No pharmacy pickup orders yet.": "目前尚無領藥單。",
|
||||
"Please select at least one medicine with quantity.": "請至少勾選一項藥品並填寫數量。",
|
||||
"Insufficient stock for :name (requested :qty, available :available).": ":name 庫存不足(需求 :qty,可用 :available)。",
|
||||
"Pharmacy pickup order created: :no": "領藥單已建立::no",
|
||||
"This pharmacy pickup order can no longer be cancelled.": "此領藥單已無法作廢。",
|
||||
"Pharmacy pickup order cancelled.": "領藥單已作廢。",
|
||||
"Scan the QR code or enter the pickup code at the machine to collect your medicine.": "請至機台掃描 QR 碼,或輸入領藥碼領取藥品。",
|
||||
"menu.special-permission": "特殊權限",
|
||||
"menu.special-permission.clear-stock": "庫存清空",
|
||||
"menu.warehouses": "倉儲管理",
|
||||
|
||||
@ -204,6 +204,9 @@
|
||||
pickup_code_enabled: settings.pickup_code_enabled === true || settings.pickup_code_enabled === 1 || settings.pickup_code_enabled === '1',
|
||||
pass_code_enabled: settings.pass_code_enabled === true || settings.pass_code_enabled === 1 || settings.pass_code_enabled === '1',
|
||||
|
||||
// 領藥單(取物單模式下的開關)
|
||||
pharmacy_pickup_enabled: settings.pharmacy_pickup_enabled === true || settings.pharmacy_pickup_enabled === 1 || settings.pharmacy_pickup_enabled === '1',
|
||||
|
||||
// 零售附加功能
|
||||
shopping_cart_enabled: machine.shopping_cart_enabled === true || machine.shopping_cart_enabled === 1 || machine.shopping_cart_enabled === '1',
|
||||
welcome_gift_enabled: machine.welcome_gift_enabled === true || machine.welcome_gift_enabled === 1 || machine.welcome_gift_enabled === '1',
|
||||
@ -619,6 +622,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 取物單模式專屬:領藥單開關 (toggle switch,比照會員系統/稅務系統) -->
|
||||
<div x-show="machineSettings.shopping_mode === 'pickup_sheet'" x-transition class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 border-l-4 border-l-emerald-500 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
|
||||
<div class="md:col-span-1">
|
||||
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Pharmacy Pickup (Rx)') }}</h4>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ __('Issue pharmacy pickup orders from the backend; patient scans QR to dispense.') }}</p>
|
||||
</div>
|
||||
<div class="md:col-span-3 flex flex-wrap gap-x-8 gap-y-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer group">
|
||||
<div class="relative inline-flex items-center">
|
||||
<input type="hidden" name="settings[pharmacy_pickup_enabled]" value="0">
|
||||
<input type="checkbox" name="settings[pharmacy_pickup_enabled]" value="1"
|
||||
x-model="machineSettings.pharmacy_pickup_enabled" class="peer sr-only">
|
||||
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 group-hover:text-slate-900 dark:group-hover:text-white transition-colors">{{ __('Pharmacy Pickup (Rx)') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 零售/金流展開部分 (僅基礎版顯示) -->
|
||||
<div x-show="machineSettings.shopping_mode === 'basic'" x-transition class="space-y-3 rounded-2xl border border-slate-200 dark:border-slate-700/60 border-l-4 border-l-cyan-500 bg-slate-50/40 dark:bg-slate-800/20 p-4">
|
||||
|
||||
|
||||
@ -99,6 +99,9 @@
|
||||
$activeFeatures[] = __('Staff Card');
|
||||
} elseif ($shoppingMode === 'pickup_sheet') {
|
||||
$activeFeatures[] = __('Pickup Sheet (Material No.)');
|
||||
if ($machine->settings['pharmacy_pickup_enabled'] ?? false) {
|
||||
$activeFeatures[] = __('Pharmacy Pickup (Rx)');
|
||||
}
|
||||
} else {
|
||||
$activeFeatures[] = __('Basic Mode');
|
||||
|
||||
@ -242,6 +245,9 @@
|
||||
$activeFeatures[] = __('Staff Card');
|
||||
} elseif ($shoppingMode === 'pickup_sheet') {
|
||||
$activeFeatures[] = __('Pickup Sheet (Material No.)');
|
||||
if ($machine->settings['pharmacy_pickup_enabled'] ?? false) {
|
||||
$activeFeatures[] = __('Pharmacy Pickup (Rx)');
|
||||
}
|
||||
} else {
|
||||
$activeFeatures[] = __('Basic Mode');
|
||||
|
||||
|
||||
181
resources/views/admin/sales/pharmacy-pickup/index.blade.php
Normal file
181
resources/views/admin/sales/pharmacy-pickup/index.blade.php
Normal file
@ -0,0 +1,181 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$statusLabels = [
|
||||
'awaiting_pickup' => __('Awaiting Pickup'),
|
||||
'completed' => __('Picked Up'),
|
||||
'failed' => __('Voided'),
|
||||
'pending' => __('Pending'),
|
||||
'abandoned' => __('Abandoned'),
|
||||
];
|
||||
$statusColors = [
|
||||
'awaiting_pickup' => 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
|
||||
'completed' => 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400',
|
||||
'failed' => 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6 pb-20">
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('menu.sales.pharmacy-pickup') }}</h1>
|
||||
<span class="text-sm text-slate-400">{{ __('Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.') }}</span>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="px-4 py-3 rounded-xl bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20 text-sm font-bold">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="px-4 py-3 rounded-xl bg-rose-50 text-rose-700 border border-rose-200 dark:bg-rose-500/10 dark:text-rose-400 dark:border-rose-500/20 text-sm font-bold">{{ session('error') }}</div>
|
||||
@endif
|
||||
@if($errors->any())
|
||||
<div class="px-4 py-3 rounded-xl bg-rose-50 text-rose-700 border border-rose-200 dark:bg-rose-500/10 dark:text-rose-400 dark:border-rose-500/20 text-sm">
|
||||
<ul class="list-disc ps-5 space-y-1">
|
||||
@foreach($errors->all() as $err)<li>{{ $err }}</li>@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 建立領藥單 --}}
|
||||
<div class="bg-white dark:bg-slate-800/60 rounded-2xl border border-slate-200 dark:border-slate-700/50 p-6 space-y-5">
|
||||
<h2 class="text-lg font-black text-slate-700 dark:text-slate-200">{{ __('Create Pharmacy Pickup Order') }}</h2>
|
||||
|
||||
@if($machines->isEmpty())
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">{{ __('No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).') }}</p>
|
||||
@else
|
||||
{{-- 選機台(變更即重新載入該機台可領藥品) --}}
|
||||
<form method="GET" action="{{ route('admin.sales.pharmacy-pickup') }}" class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-black text-slate-400 uppercase tracking-widest">{{ __('Select Machine') }}</label>
|
||||
<select name="machine_id" onchange="this.form.submit()"
|
||||
class="luxury-input min-w-[260px] rounded-xl border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 py-2.5 px-3 text-sm">
|
||||
<option value="">{{ __('-- Select Machine --') }}</option>
|
||||
@foreach($machines as $m)
|
||||
<option value="{{ $m->id }}" @selected($selectedMachine && $selectedMachine->id === $m->id)>{{ $m->name }} ({{ $m->serial_no }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if($selectedMachine)
|
||||
<form method="POST" action="{{ route('admin.sales.pharmacy-pickup.store') }}" class="space-y-4">
|
||||
@csrf
|
||||
<input type="hidden" name="machine_id" value="{{ $selectedMachine->id }}">
|
||||
|
||||
<div class="flex flex-col gap-1 max-w-xs">
|
||||
<label class="text-xs font-black text-slate-400 uppercase tracking-widest">{{ __('Pricing Slip No.') }} <span class="text-slate-300 normal-case">({{ __('optional') }})</span></label>
|
||||
<input type="text" name="pricing_slip_no" value="{{ old('pricing_slip_no') }}" maxlength="64"
|
||||
class="luxury-input rounded-xl border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 py-2.5 px-3 text-sm"
|
||||
placeholder="{{ __('e.g. the hospital pricing slip number') }}">
|
||||
</div>
|
||||
|
||||
@if($products->isEmpty())
|
||||
<p class="text-sm text-slate-500">{{ __('This machine currently has no medicine in stock.') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700/50">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900/40 text-slate-500 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="py-2.5 px-3 text-left w-10"></th>
|
||||
<th class="py-2.5 px-3 text-left">{{ __('Medicine') }}</th>
|
||||
<th class="py-2.5 px-3 text-left">{{ __('Spec') }}</th>
|
||||
<th class="py-2.5 px-3 text-center">{{ __('Available') }}</th>
|
||||
<th class="py-2.5 px-3 text-center w-32">{{ __('Quantity') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
@foreach($products as $p)
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/40">
|
||||
<td class="py-2 px-3 text-center">
|
||||
<input type="checkbox" name="products[]" value="{{ $p->product_id }}"
|
||||
class="w-4 h-4 text-cyan-500 rounded border-slate-300 dark:border-slate-600">
|
||||
</td>
|
||||
<td class="py-2 px-3 font-bold text-slate-700 dark:text-slate-200">{{ $p->name }}</td>
|
||||
<td class="py-2 px-3 text-slate-500">{{ $p->spec ?: '-' }}</td>
|
||||
<td class="py-2 px-3 text-center text-slate-600 dark:text-slate-300">{{ $p->available }}</td>
|
||||
<td class="py-2 px-3 text-center">
|
||||
<input type="number" name="qty[{{ $p->product_id }}]" value="1" min="1" max="{{ $p->available }}"
|
||||
class="w-20 text-center rounded-lg border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 py-1.5 px-2 text-sm">
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-cyan-500 hover:bg-cyan-600 text-white font-bold text-sm transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
|
||||
{{ __('Create & Generate QR') }}
|
||||
</button>
|
||||
@endif
|
||||
</form>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 領藥單列表 --}}
|
||||
<div class="bg-white dark:bg-slate-800/60 rounded-2xl border border-slate-200 dark:border-slate-700/50 p-6 space-y-4">
|
||||
<h2 class="text-lg font-black text-slate-700 dark:text-slate-200">{{ __('Pharmacy Pickup Orders') }}</h2>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700/50">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900/40 text-slate-500 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="py-2.5 px-3 text-left">{{ __('Order No.') }}</th>
|
||||
<th class="py-2.5 px-3 text-left">{{ __('Pricing Slip No.') }}</th>
|
||||
<th class="py-2.5 px-3 text-left">{{ __('Machine') }}</th>
|
||||
<th class="py-2.5 px-3 text-left">{{ __('Items') }}</th>
|
||||
<th class="py-2.5 px-3 text-center">{{ __('Pickup Code') }}</th>
|
||||
<th class="py-2.5 px-3 text-center">{{ __('Status') }}</th>
|
||||
<th class="py-2.5 px-3 text-left">{{ __('Created') }}</th>
|
||||
<th class="py-2.5 px-3 text-center">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
@forelse($orders as $order)
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/40 {{ session('created_order_id') == $order->id ? 'bg-emerald-50 dark:bg-emerald-500/5' : '' }}">
|
||||
<td class="py-2.5 px-3 font-mono font-bold text-slate-700 dark:text-slate-200">{{ $order->order_no }}</td>
|
||||
<td class="py-2.5 px-3 text-slate-500">{{ $order->pricing_slip_no ?: '-' }}</td>
|
||||
<td class="py-2.5 px-3 text-slate-600 dark:text-slate-300">{{ $order->machine?->name ?? '-' }}</td>
|
||||
<td class="py-2.5 px-3 text-slate-600 dark:text-slate-300">
|
||||
@foreach($order->items as $it)
|
||||
<div>{{ $it->product_name }} × {{ $it->quantity }}</div>
|
||||
@endforeach
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-center font-mono font-bold text-cyan-600 dark:text-cyan-400">{{ $order->pickupCode?->code ?? '-' }}</td>
|
||||
<td class="py-2.5 px-3 text-center">
|
||||
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-bold {{ $statusColors[$order->status] ?? 'bg-slate-100 text-slate-600' }}">{{ $statusLabels[$order->status] ?? $order->status }}</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-slate-500 text-xs">
|
||||
<div>{{ $order->created_at?->format('Y-m-d H:i') }}</div>
|
||||
<div class="text-slate-400">{{ $order->creator?->name }}</div>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-center">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
@if($order->status !== 'failed' && $order->pickupCode)
|
||||
<a href="{{ route('admin.sales.pharmacy-pickup.print', $order) }}" target="_blank" class="text-xs font-bold text-cyan-600 hover:text-cyan-700">{{ __('Print') }}</a>
|
||||
@endif
|
||||
@if($order->status === 'awaiting_pickup')
|
||||
<form method="POST" action="{{ route('admin.sales.pharmacy-pickup.cancel', $order) }}" onsubmit="return confirm('{{ __('Cancel this pharmacy pickup order?') }}')">
|
||||
@csrf
|
||||
<button type="submit" class="text-xs font-bold text-rose-500 hover:text-rose-600">{{ __('Cancel') }}</button>
|
||||
</form>
|
||||
@endif
|
||||
@if($order->status === 'failed' && !($order->pickupCode))
|
||||
<span class="text-slate-300">-</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="8" class="py-8 text-center text-slate-400">{{ __('No pharmacy pickup orders yet.') }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>{{ $orders->links() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
79
resources/views/admin/sales/pharmacy-pickup/print.blade.php
Normal file
79
resources/views/admin/sales/pharmacy-pickup/print.blade.php
Normal file
@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('menu.sales.pharmacy-pickup') }} {{ $order->order_no }}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "Microsoft JhengHei", "PingFang TC", "Noto Sans TC", sans-serif; margin: 0; background: #f3f4f6; color: #111827; }
|
||||
.actions { text-align: center; margin: 16px auto; }
|
||||
.btn { display: inline-block; padding: 9px 22px; background: #06b6d4; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 700; cursor: pointer; text-decoration: none; }
|
||||
.ticket { width: 360px; margin: 12px auto 28px; background: #fff; padding: 22px 24px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||
.head { text-align: center; border-bottom: 2px dashed #9ca3af; padding-bottom: 12px; margin-bottom: 12px; }
|
||||
.head h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: 4px; }
|
||||
.head .sub { font-size: 12px; color: #6b7280; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; margin: 4px 0; }
|
||||
.row .k { color: #6b7280; white-space: nowrap; }
|
||||
.row .v { font-weight: 600; text-align: right; word-break: break-all; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 12px 0 6px; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #f0f0f0; }
|
||||
th { color: #6b7280; font-weight: 700; border-bottom: 1px solid #d1d5db; }
|
||||
th.qty, td.qty { text-align: center; width: 56px; }
|
||||
.code-box { text-align: center; margin: 16px 0 4px; }
|
||||
.code-box .label { font-size: 12px; color: #6b7280; }
|
||||
.code-box .code { font-size: 32px; font-weight: 800; letter-spacing: 8px; font-family: "Courier New", monospace; }
|
||||
.qr { text-align: center; margin: 8px 0; }
|
||||
.qr svg { width: 200px; height: 200px; }
|
||||
.note { font-size: 12px; color: #4b5563; text-align: center; margin-top: 12px; border-top: 2px dashed #9ca3af; padding-top: 12px; line-height: 1.6; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.no-print { display: none !important; }
|
||||
.ticket { border: none; margin: 0 auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="actions no-print">
|
||||
<button class="btn" onclick="window.print()">{{ __('Print') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="ticket">
|
||||
<div class="head">
|
||||
<h1>{{ __('menu.sales.pharmacy-pickup') }}</h1>
|
||||
<div class="sub">{{ $order->machine?->name }}@if($order->machine?->location) · {{ $order->machine->location }}@endif</div>
|
||||
</div>
|
||||
|
||||
<div class="row"><span class="k">{{ __('Order No.') }}</span><span class="v">{{ $order->order_no }}</span></div>
|
||||
@if($order->pricing_slip_no)
|
||||
<div class="row"><span class="k">{{ __('Pricing Slip No.') }}</span><span class="v">{{ $order->pricing_slip_no }}</span></div>
|
||||
@endif
|
||||
<div class="row"><span class="k">{{ __('Created') }}</span><span class="v">{{ $order->created_at?->format('Y-m-d H:i') }}</span></div>
|
||||
@if($order->pickupCode?->expires_at)
|
||||
<div class="row"><span class="k">{{ __('Valid Until') }}</span><span class="v">{{ $order->pickupCode->expires_at->format('Y-m-d H:i') }}</span></div>
|
||||
@endif
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>{{ __('Medicine') }}</th><th class="qty">{{ __('Quantity') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($order->items as $it)
|
||||
<tr><td>{{ $it->product_name }}</td><td class="qty">{{ $it->quantity }}</td></tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="code-box">
|
||||
<div class="label">{{ __('Pickup Code') }}</div>
|
||||
<div class="code">{{ $order->pickupCode?->code ?? '--------' }}</div>
|
||||
</div>
|
||||
|
||||
@if($qrSvg)
|
||||
<div class="qr">{!! $qrSvg !!}</div>
|
||||
@endif
|
||||
|
||||
<div class="note">{{ __('Scan the QR code or enter the pickup code at the machine to collect your medicine.') }}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -216,6 +216,12 @@
|
||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Store Gifts') }}</span>
|
||||
</a></li>
|
||||
@endcan
|
||||
@can('menu.sales.pharmacy-pickup')
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.sales.pharmacy-pickup') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.sales.pharmacy-pickup') }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v11a2 2 0 002 2z" /></svg>
|
||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('menu.sales.pharmacy-pickup') }}</span>
|
||||
</a></li>
|
||||
@endcan
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@ -171,6 +171,12 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
|
||||
Route::patch('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'updateWelcomeGift'])->name('store-gifts.update');
|
||||
Route::delete('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'destroyWelcomeGift'])->name('store-gifts.destroy');
|
||||
|
||||
// 領藥單(取物單模式 / 領藥模組)— 受權限 menu.sales.pharmacy-pickup 控管
|
||||
Route::get('/pharmacy-pickup', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'index'])->name('pharmacy-pickup')->middleware('can:menu.sales.pharmacy-pickup');
|
||||
Route::post('/pharmacy-pickup', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'store'])->name('pharmacy-pickup.store')->middleware('can:menu.sales.pharmacy-pickup');
|
||||
Route::get('/pharmacy-pickup/{order}/print', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'print'])->name('pharmacy-pickup.print')->middleware('can:menu.sales.pharmacy-pickup');
|
||||
Route::post('/pharmacy-pickup/{order}/cancel', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'cancel'])->name('pharmacy-pickup.cancel')->middleware('can:menu.sales.pharmacy-pickup');
|
||||
|
||||
// 詳情路由必須放在最後,避免攔截其他具體路由
|
||||
Route::get('/{order}', [App\Http\Controllers\Admin\SalesController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user