Compare commits

..

1 Commits

Author SHA1 Message Date
8977e7eb0f feat(pharmacy-pickup): 領藥單模組(後台建單→QR→機台B660掃碼→出貨回報)
- 機台系統設定:取物單模式(pickup_sheet)下新增「領藥單」開關 pharmacy_pickup_enabled,B014 下發 FunctionSet.PharmacyPickup
- 權限:銷售管理→領藥單 menu.sales.pharmacy-pickup(角色/帳號可勾選授權,租戶模板預設關)
- 資料模型:orders 加 order_type/pricing_slip_no/created_by(領藥單序號=flow_id)、pickup_codes.slot_no 可空
- 後台:PharmacyPickupController + PharmacyPickupService(以商品為單位建單、庫存不預扣)+ 建單/列表/列印頁(QR內嵌,不顯姓名)
- B660:領藥單回多貨道 items[](保留 slot_no 向後相容)+ 模式 gate + 標記已領
- 出貨回報:finalizePharmacyDispense(獨立於銷售/發票/閉環)+ flow_id 終態冪等 + 庫存只扣一次
- 修正 PickupCode::isValid() 對 null expires_at 的 NPE、status 補入 fillable

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:54:04 +08:00
82 changed files with 474 additions and 5914 deletions

61
PHARMACY_PICKUP_DESIGN.md Normal file
View 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 資料模型**:上述 migrationOrder 加常數/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

View File

@ -95,7 +95,6 @@ class AnalysisController extends Controller
7 => __('Welcome Gift'),
8 => __('Questionnaire'),
9 => __('Coin Acceptor'),
10 => __('Mobile Pay'), // NFC 手機感應(手機支付)
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
41 => __('Staff Card'),
42 => __('Pickup Voucher'),

View File

@ -75,10 +75,8 @@ class MachineSettingController extends AdminController
break;
case 'permissions':
// 套用層級可見範圍:系統管理員看全部;租戶僅看可見範圍內帳號(避免跨租戶洩漏)。
$userQuery = \App\Models\System\User::query()
->where('is_admin', true)
->visibleTo($currentUser)
->with(['company', 'machines']);
if ($search) {
$userQuery->where(function($q) use ($search) {
@ -123,7 +121,6 @@ class MachineSettingController extends AdminController
$userQuery = \App\Models\System\User::query()
->where('is_admin', true)
->visibleTo($currentUser)
->with(['company', 'machines']);
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
@ -412,9 +409,6 @@ class MachineSettingController extends AdminController
$jsonSettings['pickup_code_enabled'] = false;
$jsonSettings['pass_code_enabled'] = false;
// 副櫃系統(格子櫃功能)僅基礎版可用,非基礎版一律關閉
$jsonSettings['subcabinet_enabled'] = false;
} else {
$jsonSettings['credit_card_enabled'] = isset($settings['credit_card_enabled']) ? (bool)$settings['credit_card_enabled'] : false;
$jsonSettings['mobile_pay_enabled'] = isset($settings['mobile_pay_enabled']) ? (bool)$settings['mobile_pay_enabled'] : false;
@ -438,9 +432,6 @@ class MachineSettingController extends AdminController
$jsonSettings['pickup_code_enabled'] = isset($settings['pickup_code_enabled']) ? (bool)$settings['pickup_code_enabled'] : false;
$jsonSettings['pass_code_enabled'] = isset($settings['pass_code_enabled']) ? (bool)$settings['pass_code_enabled'] : false;
// 副櫃系統(格子櫃功能):基礎版可設定
$jsonSettings['subcabinet_enabled'] = isset($settings['subcabinet_enabled']) ? (bool)$settings['subcabinet_enabled'] : false;
}
foreach ($allowedFields as $field) {

View File

@ -32,9 +32,10 @@ class MachinePermissionController extends AdminController
}])
->whereNotNull('company_id');
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
$userQuery->visibleTo($currentUser);
if ($currentUser->isSystemAdmin() && $company_id) {
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
if (!$currentUser->isSystemAdmin()) {
$userQuery->where('company_id', $currentUser->company_id);
} elseif ($company_id) {
// 系統管理員的篩選邏輯
$userQuery->where('company_id', $company_id);
}
@ -53,31 +54,6 @@ class MachinePermissionController extends AdminController
return view('admin.machines.permissions', compact('users_list', 'companies'));
}
/**
* 操作者「可授權」的機台 ID 集合(授權子集約束):
* - 系統管理員:指定公司(或全部)的所有機台
* - 主帳號:同公司全部機台
* - 子帳號僅自己被授權的機台machine_user
*/
private function assignableMachineIds(User $operator, ?int $companyId): \Illuminate\Support\Collection
{
// 目標帳號無所屬公司(如系統管理員帳號)時,沒有可授權的公司機台。
if (is_null($companyId)) {
return collect();
}
if ($operator->isSystemAdmin() || $operator->is_admin) {
return Machine::withoutGlobalScope('machine_access')
->where('company_id', $companyId)
->pluck('id');
}
return $operator->machines()
->withoutGlobalScope('machine_access')
->where('machines.company_id', $companyId)
->pluck('machines.id');
}
/**
* AJAX: 取得特定帳號的機台分配狀態
*/
@ -85,18 +61,17 @@ class MachinePermissionController extends AdminController
{
$currentUser = auth()->user();
// 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->canManageAccount($user)) {
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// 可授權機台清單限縮為「操作者本身可授權」的機台子集,避免把自己沒有的機台授權出去。
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
$machines = Machine::withoutGlobalScope('machine_access')
->whereIn('id', $assignableIds)
->where('company_id', $user->company_id)
->get(['id', 'name', 'serial_no']);
$assignedIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id')->toArray();
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
return response()->json([
'user' => $user,
@ -112,8 +87,8 @@ class MachinePermissionController extends AdminController
{
$currentUser = auth()->user();
// 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->canManageAccount($user)) {
// 安全檢查
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
@ -122,27 +97,20 @@ class MachinePermissionController extends AdminController
'machine_ids.*' => 'exists:machines,id'
]);
// 授權子集約束:被指派的機台必須是「操作者本身可授權機台」的子集,
// 既確保同公司,也避免子帳號把自己沒被授權的機台授權給他人。
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
$submittedIds = collect($request->machine_ids ?? [])->map(fn ($id) => (int) $id)->unique();
if ($submittedIds->diff($assignableIds)->isNotEmpty()) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯)
if ($request->has('machine_ids')) {
$machineIds = array_unique($request->machine_ids);
$validCount = Machine::withoutGlobalScope('machine_access')
->where('company_id', $user->company_id)
->whereIn('id', $machineIds)
->count();
if ($validCount !== count($machineIds)) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
}
}
// 只在「操作者可授權子集」範圍內做增刪;範圍外的既有授權一律保留,避免覆蓋式 sync 誤刪
// 操作者看不到(不在其可授權子集內)的機台。明確 detach/attach 以避開 machine_access
// 全域 scope 對 sync 取「現有附加」造成的干擾(該 scope 依登入者過濾 Machine 查詢)。
$existingIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id');
$currentInScope = $existingIds->intersect($assignableIds);
$toAttach = $submittedIds->diff($currentInScope);
$toDetach = $currentInScope->diff($submittedIds);
if ($toDetach->isNotEmpty()) {
$user->machines()->detach($toDetach->all());
}
if ($toAttach->isNotEmpty()) {
$user->machines()->attach($toAttach->all());
}
$user->machines()->sync($request->machine_ids ?? []);
$message = __('Machine permissions updated successfully.');
session()->flash('success', $message);

View File

@ -2,15 +2,10 @@
namespace App\Http\Controllers\Admin;
use App\Jobs\Product\SendProductSyncCommandJob;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineProductPrice;
use App\Models\Product\Product;
use App\Models\System\Company;
use App\Services\Machine\MachineService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MachineController extends AdminController
@ -208,12 +203,9 @@ class MachineController extends AdminController
->get();
// 取得該機台目前所有等待中的庫存更新指令 (Pending Commands)
// 僅將「1 分鐘內」的 pending 視為鎖定中;超過 1 分鐘未收到機台回傳 (ACK)
// 視為逾時,自動解除鎖定,讓使用者可重新編輯與重新下發 (與 dispense 逾時邏輯一致)
$pendingSlotNos = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'pending')
->where('created_at', '>=', now()->subMinute())
->get()
->pluck('payload.slot_no')
->flatten()
@ -265,117 +257,6 @@ class MachineController extends AdminController
}
}
/**
* AJAX: 遠端鎖定/解鎖單一貨道(暫停販售)。
* updateSlotExpiry 共用 updateSlot 下發 update_inventory只帶 is_locked不動效期/庫存。
*/
public function toggleSlotLock(Request $request, Machine $machine)
{
$validated = $request->validate([
'slot_no' => 'required|integer',
'is_locked' => 'required|boolean',
]);
try {
$this->machineService->updateSlot($machine, $validated, auth()->id());
return response()->json([
'success' => true,
'message' => $validated['is_locked'] ? __('Slot locked.') : __('Slot unlocked.'),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
/**
* 機台專屬定價頁:列出該機台「所屬公司的完整商品目錄」(非僅貨道商品),
* 可在上貨前先把機台價/會員價訂好。未填=沿用全域 products 價。
*/
public function pricing(Machine $machine): View
{
$products = Product::where('company_id', $machine->company_id)
->active()
->orderBy('name')
->get(['id', 'name', 'image_url', 'barcode', 'price', 'member_price']);
$overrides = $machine->productPrices()->get()->keyBy('product_id');
$items = $products->map(function (Product $p) use ($overrides) {
$ov = $overrides->get($p->id);
return [
'product_id' => $p->id,
'name' => $p->name,
'image_url' => $p->image_url,
'barcode' => $p->barcode,
'global_price' => (float) $p->price,
'global_member_price' => $p->member_price !== null ? (float) $p->member_price : null,
'override_price' => $ov && $ov->price !== null ? (float) $ov->price : null,
'override_member_price' => $ov && $ov->member_price !== null ? (float) $ov->member_price : null,
];
})->values();
return view('admin.machines.pricing', compact('machine', 'items'));
}
/**
* 批次儲存機台專屬定價。
*
* - price/member_price 皆空 刪除覆蓋,回到全域價。
* - 任一有值 建立/更新覆蓋;會員價於 B012 下發時自動夾為不高於機台售價。
* - quiet 寫入避免逐筆觸發 observer 重複推送,最後統一推一次 B012。
* - 商品須屬於該機台公司(跨租戶防護)。
*/
public function updatePricing(Request $request, Machine $machine): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'items' => 'required|array',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.price' => 'nullable|numeric|min:1|max:99999999',
'items.*.member_price' => 'nullable|numeric|min:1|max:99999999',
]);
$allowedProductIds = Product::where('company_id', $machine->company_id)
->pluck('id')
->flip();
DB::transaction(function () use ($validated, $machine, $allowedProductIds) {
foreach ($validated['items'] as $item) {
$productId = (int) $item['product_id'];
if (!$allowedProductIds->has($productId)) {
continue;
}
$price = isset($item['price']) && $item['price'] !== '' ? (float) $item['price'] : null;
$memberPrice = isset($item['member_price']) && $item['member_price'] !== '' ? (float) $item['member_price'] : null;
if ($price === null && $memberPrice === null) {
$machine->productPrices()->where('product_id', $productId)->delete();
continue;
}
$row = MachineProductPrice::firstOrNew([
'machine_id' => $machine->id,
'product_id' => $productId,
]);
$row->price = $price;
$row->member_price = $memberPrice;
$row->saveQuietly();
}
});
// 統一推一次 B012 同步給該機台(覆蓋於請求當下即時套用,無需失效公司快取)
SendProductSyncCommandJob::dispatch($machine->id, __('Machine pricing updated'), Auth::id());
return response()->json([
'success' => true,
'message' => __('Machine pricing saved and sync command pushed.'),
]);
}
/**
* 取得機台統計數據 (AJAX)
*/

View File

@ -14,8 +14,10 @@ class PermissionController extends Controller
$user = auth()->user();
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
// 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。
$query->visibleTo($user);
// 租戶隔離:租戶只能看到自己公司的角色
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search
$search = request()->input('roles_search', request()->input('search'));
@ -86,11 +88,6 @@ class PermissionController extends Controller
$role = \App\Models\System\Role::findOrFail($id);
$user = auth()->user();
// 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。
if (!$role->canBeManagedBy($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
$all_permissions = $this->getFilteredPermissions($user);
@ -129,7 +126,6 @@ class PermissionController extends Controller
'name' => $validated['name'],
'guard_name' => 'web',
'company_id' => $is_system ? null : auth()->user()->company_id,
'created_by' => auth()->id(),
'is_system' => $is_system,
]);
@ -186,11 +182,6 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.'));
}
// 層級隔離:子帳號只能修改自己建立的角色。
if (!$role->canBeManagedBy(auth()->user())) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
$updateData = [
@ -232,20 +223,10 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.'));
}
// 公司主帳號角色保護:比照 super-admin公司層級的「管理員」角色為該公司最高權限角色不可刪除。
if ($role->is_company_admin) {
return redirect()->back()->with('error', __('The company admin role cannot be deleted.'));
}
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.'));
}
// 層級隔離:子帳號只能刪除自己建立的角色。
if (!$role->canBeManagedBy(auth()->user())) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
if ($role->users()->count() > 0) {
return redirect()->back()->with('error', __('Cannot delete role with active users.'));
}
@ -270,8 +251,10 @@ class PermissionController extends Controller
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
// ── 帳號列表 ──────────────────────────────────────────────────
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$usersQuery->where('company_id', $user->company_id);
}
if ($search = $request->input('accounts_search', $request->input('search'))) {
$usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
@ -286,24 +269,22 @@ class PermissionController extends Controller
$accounts_per_page = $request->input('accounts_per_page', 10);
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
// Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。
// 預先載入 permissions 以避免下方子集過濾造成 N+1。
$rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user);
$roles = $rolesForSelect->get();
// 子集過濾:租戶只能指派「權限為自身子集」的角色(與 store/update 的後端 guard 一致,避免下拉出現越權選項)。
// Modal 用的角色選單(不分頁)
$rolesForSelect = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$operatorPerms = $user->getAllPermissions()->pluck('name');
$roles = $roles->filter(
fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty()
)->values();
$rolesForSelect->forCompany($user->company_id);
}
$roles = $rolesForSelect->get();
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
$paginated_roles = collect();
$all_permissions = collect();
if ($isSubAccountRoute) {
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if (!$user->isSystemAdmin()) {
$rolesQuery->where('company_id', $user->company_id);
}
if ($search = $request->input('roles_search')) {
$rolesQuery->where('name', 'like', "%{$search}%");
}
@ -347,12 +328,6 @@ class PermissionController extends Controller
'phone' => 'nullable|string|max:20',
]);
// 深度上限level >= MAX_LEVEL 的子帳號不可再建立下層(系統管理員不受限)。
// 此為後端硬擋,獨立於權限判斷(即使持有子帳號管理權限也不得突破層級)。
if (!auth()->user()->canCreateSubAccount()) {
return redirect()->back()->with('error', __('Sub-accounts at this level cannot create further sub-accounts.'));
}
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
@ -399,7 +374,6 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $company_id,
'is_system' => false,
'is_company_admin' => true,
]);
$newRole->syncPermissions($role->getPermissionNames());
$role = $newRole;
@ -409,39 +383,6 @@ class PermissionController extends Controller
}
}
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免透過指派高權限角色造成權限提升。
if (!auth()->user()->isSystemAdmin()) {
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
if (collect($role->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
}
}
// 主帳號唯一性:一家公司只在「尚無主帳號」時,由系統管理員建立的第一個帳號自動成為主帳號 (is_admin)。
// 之後建立的帳號(含子帳號自建)一律 is_admin=false確保每家公司恰有一個主帳號。
$creator = auth()->user();
$companyHasAdmin = $company_id
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->exists()
: false;
$isMainAccount = $creator->isSystemAdmin() && !empty($company_id) && !$companyHasAdmin;
// 樹狀層級歸屬:
// - 主帳號level 0、parent_id null。
// - 系統管理員建立的非主帳號掛在該公司主帳號下、level 1。
// - 租戶自建掛在建立者下、level = 建立者 level + 1。
if ($isMainAccount) {
$parentId = null;
$level = 0;
} elseif ($creator->isSystemAdmin()) {
$parentId = $company_id
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->value('id')
: null;
$level = $parentId ? 1 : 0;
} else {
$parentId = $creator->id;
$level = $creator->level + 1;
}
$user = \App\Models\System\User::create([
'name' => $validated['name'],
'username' => $validated['username'],
@ -449,10 +390,8 @@ class PermissionController extends Controller
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
'status' => $validated['status'],
'company_id' => $company_id,
'parent_id' => $parentId,
'level' => $level,
'phone' => $validated['phone'] ?? null,
'is_admin' => $isMainAccount,
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
]);
$user->assignRole($role);
@ -471,11 +410,6 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.'));
}
// 層級越權防護:租戶只能管理可管轄範圍內的帳號(主帳號=同公司全部;子帳號=自己直接建立的下層)。
if (!auth()->user()->canManageAccount($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'username' => 'required|string|max:255|unique:users,username,' . $id,
@ -566,7 +500,7 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $target_company_id,
'is_system' => false,
'is_company_admin' => true,
'is_admin' => true,
]);
$newRole->syncPermissions($roleObj->getPermissionNames());
$roleObj = $newRole;
@ -575,14 +509,6 @@ class PermissionController extends Controller
}
}
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免權限提升。
if (!auth()->user()->isSystemAdmin()) {
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
if (collect($roleObj->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
}
}
$user->update($updateData);
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
@ -601,31 +527,15 @@ class PermissionController extends Controller
public function destroyAccount($id)
{
$user = \App\Models\System\User::findOrFail($id);
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.'));
}
// 層級越權防護:租戶只能刪除可管轄範圍內的帳號。
if (!auth()->user()->canManageAccount($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
}
if ($user->id === auth()->id()) {
return redirect()->back()->with('error', __('You cannot delete your own account.'));
}
// 主帳號保護:若該帳號是公司唯一主帳號,且公司仍有其他帳號,禁止刪除(須先轉移主帳號身分),
// 以維持「有帳號的公司恰有一個主帳號」的不變量。公司僅剩此主帳號時允許刪除(公司歸零)。
if ($user->is_admin && $user->company_id) {
$hasOtherAccounts = \App\Models\System\User::where('company_id', $user->company_id)
->where('id', '!=', $user->id)
->exists();
if ($hasOtherAccounts) {
return redirect()->back()->with('error', __('Cannot delete the company main account while other accounts exist. Please transfer the main account role first.'));
}
}
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位
$timestamp = now()->getTimestamp();
$user->username = $user->username . '.deleted.' . $timestamp;
@ -646,11 +556,6 @@ class PermissionController extends Controller
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
}
// 層級越權防護:租戶只能切換可管轄範圍內的帳號狀態。
if (!auth()->user()->canManageAccount($user)) {
return back()->with('error', __('You do not have permission to manage this account.'));
}
$user->status = $user->status ? 0 : 1;
$user->save();

View File

@ -59,20 +59,6 @@ class PharmacyPickupController extends AdminController
]);
}
/**
* AJAX回傳某機台目前可領藥品建立領藥單彈窗用對齊取貨碼 slots-ajax 模式)。
*/
public function productsAjax(Machine $machine)
{
$enabled = ($machine->settings['shopping_mode'] ?? null) === 'pickup_sheet'
&& (bool) ($machine->settings['pharmacy_pickup_enabled'] ?? false);
abort_unless($enabled, 403);
return response()->json([
'products' => $this->service->availableProducts($machine)->values(),
]);
}
public function store(Request $request)
{
$request->validate([

View File

@ -584,136 +584,6 @@ class ProductController extends Controller
}
}
/**
* 匯出商品資料CSV / Excel套用與 index 相同的篩選(搜尋/分類/公司)。
* 參考 SalesController::handleExport 的串流下載模式;欄位順序對齊匯入範本,方便編輯後回匯入。
*/
public function export(Request $request)
{
$user = auth()->user();
$type = $request->input('export', 'csv');
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
// 與 index 相同的商品查詢與篩選(租戶隔離沿用 Product 全域 scope
$query = Product::with(['category.translations', 'translations', 'company']);
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('spec', 'like', "%{$search}%");
});
}
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
if ($user->isSystemAdmin() && $request->filled('product_company_id')) {
$query->where('company_id', $request->product_company_id);
}
// 欄位順序對齊匯入範本ProductImportService::downloadTemplate
$headers = [
'分類',
'商品名稱(zh_TW)',
'商品名稱(en)',
'商品名稱(ja)',
'條碼',
'規格',
'生產公司',
'售價',
'會員價',
'成本',
'履帶貨道上限',
'彈簧貨道上限',
'物料代碼',
'全額點數',
'半額點數',
'半額折抵金額',
'是否啟用',
'圖片檔名',
];
$filename = '商品資料_' . now()->format('YmdHis') . '.' . $ext;
$callback = function () use ($query, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟不亂碼
fputcsv($file, $headers);
}
$locale = app()->getLocale();
$query->orderBy('id')->chunk(500, function ($products) use ($file, $isExcel, $locale) {
foreach ($products as $p) {
$meta = is_array($p->metadata) ? $p->metadata : [];
$nameEn = optional($p->translations->firstWhere('locale', 'en'))->value;
$nameJa = optional($p->translations->firstWhere('locale', 'ja'))->value;
$categoryName = '';
if ($p->category) {
$catTrans = $p->category->translations->firstWhere('locale', $locale)
?? $p->category->translations->firstWhere('locale', 'zh_TW');
$categoryName = $catTrans->value ?? ($p->category->name ?? '');
}
$imageFilename = $p->image_url ? basename($p->image_url) : '';
$row = [
$categoryName,
$p->name,
$nameEn,
$nameJa,
$p->barcode,
$p->spec,
$p->manufacturer,
$p->price,
$p->member_price,
$p->cost,
$p->track_limit,
$p->spring_limit,
$meta['material_code'] ?? '',
$meta['points_full'] ?? 0,
$meta['points_half'] ?? 0,
$meta['points_half_amount'] ?? 0,
$p->is_active ? 1 : 0,
$imageFilename,
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $cell) {
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
}
public function downloadTemplate(ProductImportService $importService)
{
return $importService->downloadTemplate();

View File

@ -142,7 +142,7 @@ class RemoteController extends Controller
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'command_type' => 'required|string|in:reboot,reboot_force,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
'amount' => 'nullable|integer|min:0',
'slot_no' => 'nullable|string',
'note' => 'nullable|string|max:255',

View File

@ -19,7 +19,6 @@ use App\Models\Machine\Machine;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\System\SystemOperationLog;
@ -35,10 +34,7 @@ class SalesController extends Controller
$search = $request->input('search');
$machineId = $request->input('machine_id');
$paymentType = $request->input('payment_type');
// 付款狀態預設只看「已完成」:避免客戶把 failed(付款失敗/逾時) 誤會成刷卡失敗。
// 「所有付款狀態」為明確選項 status=all非空值AJAX 才不會當空值丟掉)。
$status = $request->input('status', 'completed');
$deliveryStatus = $request->input('delivery_status');
$status = $request->input('status');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
@ -59,7 +55,6 @@ class SalesController extends Controller
'machine_id' => $machineId,
'payment_type' => $paymentType,
'status' => $status,
'delivery_status' => $deliveryStatus,
'start_date' => $request->input('start_date'), // 保留原始輸入以顯示於 UI (如果是隱含的則 UI 顯示空)
'end_date' => $request->input('end_date'),
],
@ -88,16 +83,6 @@ class SalesController extends Controller
$invoicesQuery->whereBetween('created_at', [$start, $end]);
$dispenseQuery->whereBetween('machine_time', [$start, $end]);
// 機台權限隔離:非系統管理員只能看到「自己可存取機台」的交易。沿用 Machine 的 machine_access 全域 scope
// 取得可存取機台集合(與上方機台下拉清單一致),修正訂單/發票/出貨清單先前未依機台授權過濾、
// 導致無該機台權限的帳號仍可見其交易紀錄的資料隔離漏洞。
if (!auth()->user()->isSystemAdmin()) {
$accessibleMachineIds = Machine::pluck('id');
$ordersQuery->whereIn('machine_id', $accessibleMachineIds);
$invoicesQuery->whereIn('machine_id', $accessibleMachineIds);
$dispenseQuery->whereIn('machine_id', $accessibleMachineIds);
}
// 共用過濾器:機台
if ($machineId) {
$ordersQuery->where('machine_id', $machineId);
@ -140,12 +125,7 @@ class SalesController extends Controller
// 訂單專用過濾器
if ($tab === 'orders') {
if ($paymentType) $ordersQuery->where('payment_type', $paymentType);
if ($status && $status !== 'all') $ordersQuery->where('status', $status);
// 出貨狀態:唯有付款成功(completed/paid)的單才有出貨結果,需與列表顯示邏輯 (hasDeliveryOutcome) 一致
if ($deliveryStatus !== null && $deliveryStatus !== '') {
$ordersQuery->whereIn('status', [Order::STATUS_COMPLETED, 'paid'])
->where('delivery_status', $deliveryStatus);
}
if ($status) $ordersQuery->where('status', $status);
}
// 發票專用過濾器狀態pending/issued/failed/void
@ -166,11 +146,6 @@ class SalesController extends Controller
$data['invoices'] = $invoicesQuery->latest()->paginate($perPage, ['*'], 'invoices_page')->withQueryString();
$data['dispenseLogs'] = $dispenseQuery->latest()->paginate($perPage, ['*'], 'dispense_page')->withQueryString();
// 出貨失敗補償取貨碼:僅掛載既有碼供顯示;產碼由 + 按鈕手動觸發 generateOrderPickupCode()
if ($tab === 'orders') {
$this->attachOrderPickupCodes($data['orders']->getCollection());
}
if ($isAjax) {
return response()->json([
'success' => true,
@ -182,98 +157,6 @@ class SalesController extends Controller
return view('admin.sales.index', $data);
}
/**
* 掛載訂單的補償取貨碼(batch_no='COMP{id}')供銷售頁顯示。只讀不產碼。
*/
private function attachOrderPickupCodes($orders): void
{
if ($orders->isEmpty()) {
return;
}
$batchNos = $orders->map(fn ($o) => 'COMP' . $o->id)->all();
$codesByBatch = PickupCode::whereIn('batch_no', $batchNos)->get()->groupBy('batch_no');
foreach ($orders as $order) {
$order->setRelation('compensationCodes', $codesByBatch->get('COMP' . $order->id, collect()));
}
}
/**
* 手動產生「出貨失敗補償取貨碼」:由銷售頁 + 按鈕、前端確認後 POST 觸發。
* 僅限「付款成功(completed/paid)但出貨失敗/部分(delivery 0/2)」的訂單;依每商品缺量各產一碼
* (綁機台+商品、7 天有效、usage_limit=缺量);冪等(batch_no='COMP{id}')
* 不設 order_id——取貨碼核銷時 order_id 會被回填為「新出貨單」。
*/
public function generateOrderPickupCode(Request $request, Order $order)
{
if (!Auth::user()->isSystemAdmin() && $order->machine_id && !Machine::whereKey($order->machine_id)->exists()) {
abort(403);
}
$isPaid = in_array($order->status, [Order::STATUS_COMPLETED, 'paid'], true);
$failedDelivery = in_array((int) $order->delivery_status, [Order::DELIVERY_STATUS_FAILED, Order::DELIVERY_STATUS_PARTIAL], true);
if (!$isPaid || !$failedDelivery || !$order->machine_id) {
return response()->json(['success' => false, 'message' => __('This order is not eligible for a pickup code.')], 422);
}
$batchNo = 'COMP' . $order->id;
$existing = PickupCode::where('batch_no', $batchNo)->get();
if ($existing->isNotEmpty()) {
return response()->json(['success' => true, 'message' => __('Pickup code already generated.'), 'codes' => $existing->pluck('code')]);
}
$machine = $order->machine ?: Machine::find($order->machine_id);
if (!$machine) {
return response()->json(['success' => false, 'message' => __('Machine not found.')], 422);
}
$ordered = $order->items->groupBy('product_id')->map(fn ($g) => (int) $g->sum('quantity'));
$dispensedOk = DispenseRecord::where('order_id', $order->id)
->where('dispense_status', 1)
->get()->groupBy('product_id')->map(fn ($g) => (int) $g->sum('amount'));
$created = [];
foreach ($ordered as $productId => $orderedQty) {
if (!$productId) {
continue;
}
$shortfall = $orderedQty - (int) ($dispensedOk[$productId] ?? 0);
if ($shortfall <= 0) {
continue;
}
$code = PickupCode::create([
'company_id' => $order->company_id ?? $machine->company_id,
'machine_id' => $order->machine_id,
'product_id' => $productId,
'code' => PickupCode::generateUniqueCode($order->machine_id),
'slug' => Str::random(16),
'batch_no' => $batchNo,
'status' => 'active',
'usage_limit' => $shortfall,
'usage_count' => 0,
'expires_at' => now()->addDays(7),
'created_by' => Auth::id(),
]);
$created[] = $code->code;
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'create',
'target_id' => $code->id,
'target_type' => PickupCode::class,
'new_values' => $code->toArray() + ['source' => 'failed_delivery_compensation', 'source_order_id' => $order->id],
]);
}
if (empty($created)) {
return response()->json(['success' => false, 'message' => __('No undelivered items to compensate.')], 422);
}
return response()->json(['success' => true, 'message' => __('Pickup code generated.'), 'codes' => $created]);
}
/**
* 取得單筆交易詳情 (用於 Slide-over)
*/
@ -297,170 +180,6 @@ class SalesController extends Controller
]);
}
/**
* 手動補單:取得指定機台的貨道(含商品)與電子發票開關,供補單彈窗動態載入。
* 僅限系統管理員(整個補單功能限系統管理員)。
*/
public function manualMachineSlots(Request $request)
{
if (!Auth::user()->isSystemAdmin()) {
abort(403);
}
$machine = Machine::with(['slots.product:id,name,price'])
->findOrFail($request->input('machine_id'));
$slots = $machine->slots
->sortBy(fn ($s) => str_pad(ltrim($s->slot_no, '0') ?: '0', 6, '0', STR_PAD_LEFT))
->values()
->map(fn ($slot) => [
'slot_no' => $slot->slot_no,
'product_id' => $slot->product_id,
'product_name' => $slot->product?->localized_name ?? __('Unknown Product'),
'price' => $slot->product?->price !== null ? (float) $slot->product->price : 0,
'stock' => (int) $slot->stock,
]);
return response()->json([
'success' => true,
'tax_invoice_enabled' => (bool) $machine->tax_invoice_enabled,
'slots' => $slots,
]);
}
/**
* 手動補單:機台斷線/漏報時人工補登銷售紀錄。
*
* 僅限系統管理員。複用 TransactionService::createManualOrder與機台上報共用扣庫存開發票邏輯
* 扣庫存與開立電子發票皆為選配;開發票受機台「電子發票開關」閘門限制(後端再驗一次)。
*/
public function storeManualOrder(Request $request, \App\Services\Transaction\TransactionService $service)
{
// 權限閘門:補單僅限平台系統管理員(前端僅隱藏入口,真正把關在後端,防偽造 POST
if (!Auth::user()->isSystemAdmin()) {
abort(403, __('Only system administrators can create manual orders'));
}
$validated = $request->validate([
'machine_id' => ['required', 'exists:machines,id'],
'payment_type' => ['required', 'integer', 'in:' . implode(',', array_keys(Order::getPaymentTypeLabels()))],
'occurred_at' => ['nullable', 'date'],
'deduct_stock' => ['nullable', 'boolean'],
'remark' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.slot_no' => ['nullable', 'string', 'max:20'],
'items.*.price' => ['required', 'numeric', 'min:0'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:999'],
'issue_invoice' => ['nullable', 'boolean'],
'invoice_type' => ['nullable', 'required_if:issue_invoice,1', 'in:b2c,taxid,mobile,citizen,donation'],
'invoice_value' => ['nullable', 'string', 'max:64'],
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 組裝發票輸入(僅在勾選開立、且機台開關開啟時)。後端閘門:機台關閉電子發票一律不開。
$invoice = null;
if (!empty($validated['issue_invoice'])) {
if (!$machine->tax_invoice_enabled) {
return $this->manualOrderResponse($request, false, __('This machine has e-invoice disabled; cannot issue'));
}
$invoice = $this->buildManualInvoiceInput($validated['invoice_type'] ?? 'b2c', trim((string) ($validated['invoice_value'] ?? '')));
if (isset($invoice['error'])) {
return $this->manualOrderResponse($request, false, $invoice['error']);
}
}
try {
$order = $service->createManualOrder([
'serial_no' => $machine->serial_no,
'created_by' => Auth::id(),
'payment_type' => (int) $validated['payment_type'],
'occurred_at' => $validated['occurred_at'] ?? null,
'deduct_stock' => !empty($validated['deduct_stock']),
'remark' => $validated['remark'] ?? null,
'items' => array_map(fn ($i) => [
'product_id' => (int) $i['product_id'],
'slot_no' => $i['slot_no'] ?? null,
'price' => (float) $i['price'],
'quantity' => (int) $i['quantity'],
], $validated['items']),
'invoice' => $invoice,
]);
} catch (\Throwable $e) {
\Log::error('Manual order creation failed: ' . $e->getMessage(), [
'machine_id' => $machine->id,
'user_id' => Auth::id(),
]);
return $this->manualOrderResponse($request, false, __('Failed to create manual order'));
}
// 審計日誌:留痕「誰/何時/對哪台機台補了什麼、是否開發票/扣庫存」。
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'manual_order',
'action' => 'create',
'target_id' => $order->id,
'target_type' => Order::class,
'new_values' => [
'order_no' => $order->order_no,
'flow_id' => $order->flow_id,
'machine_id' => $machine->id,
'total_amount' => $order->total_amount,
'payment_type' => $order->payment_type,
'deduct_stock' => !empty($validated['deduct_stock']),
'issue_invoice' => $invoice !== null,
'occurred_at' => optional($order->machine_time)->toDateTimeString(),
],
]);
return $this->manualOrderResponse($request, true, __('Manual order created: :no', ['no' => $order->order_no]));
}
/**
* 將補單表單的發票類型/輸入值轉成 recordInvoice 可用的欄位。
* reissue() business_tax_id / carrier_id / love_code 自動判別綠界載具類型與 Print 旗標。
*/
private function buildManualInvoiceInput(string $type, string $value): array
{
switch ($type) {
case 'b2c': // 一般 B2C綠界會員載具Print=0不可列印
return [];
case 'taxid': // 統一編號Print=1可列印
if (!preg_match('/^\d{8}$/', $value)) {
return ['error' => __('Tax ID must be 8 digits')];
}
return ['business_tax_id' => $value];
case 'mobile': // 手機條碼載具8 碼,/ 開頭)
if (!preg_match('#^/[0-9A-Z.\-+]{7}$#', $value)) {
return ['error' => __('Invalid mobile barcode carrier')];
}
return ['carrier_id' => $value];
case 'citizen': // 自然人憑證載具16 碼2 英文 + 14 數字)
if (!preg_match('/^[A-Z]{2}\d{14}$/', $value)) {
return ['error' => __('Invalid citizen digital certificate carrier')];
}
return ['carrier_id' => $value];
case 'donation': // 捐贈(愛心碼 3~7 碼數字)
if (!preg_match('/^\d{3,7}$/', $value)) {
return ['error' => __('Donation love code must be 3 to 7 digits')];
}
return ['love_code' => $value];
default:
return ['error' => __('Invalid invoice type')];
}
}
/** 補單回應AJAX 回 JSON、一般表單回 back()。 */
private function manualOrderResponse(Request $request, bool $success, string $message)
{
if ($request->expectsJson()) {
return response()->json(['success' => $success, 'message' => $message], $success ? 200 : 422);
}
return back()->with($success ? 'success' : 'error', $message);
}
// 取貨碼設定
public function pickupCodes(Request $request)
{
@ -473,17 +192,9 @@ class SalesController extends Controller
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 取貨碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PickupCode::with(['machine.slots.product', 'product', 'creator', 'order'])
->whereHas('machine')
->latest();
$query = PickupCode::with(['machine.slots.product', 'creator', 'order'])->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -508,12 +219,7 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料machine_id 為 null 的非機台紀錄仍保留)
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order'])
->where(function ($q) {
$q->whereNull('machine_id')
->orWhereHas('machine');
});
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order']);
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -579,105 +285,25 @@ class SalesController extends Controller
*/
public function storePickupCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆。
// 後端強制把關,避免前端被繞過送出大量產碼請求。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
// 取貨碼改綁「商品」:新流程傳 product_id舊的綁「貨道」流程仍可傳 slot_no向後相容
'product_id' => 'required_without:slot_no|nullable|exists:products,id',
'slot_no' => 'required_without:product_id|nullable|string',
'slot_no' => 'required|string',
'usage_limit' => 'nullable|integer|min:1|max:20',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
// 綁商品時驗證該商品屬於此機台所屬公司(不要求機台當下有貨道;機台未上架仍可產碼,
// 由前端提示、刷碼時若無貨道再由 B660 擋下)。
if (!empty($validated['product_id'])) {
$belongsToCompany = \App\Models\Product\Product::where('id', $validated['product_id'])
->where('company_id', $machine->company_id)
->exists();
if (!$belongsToCompany) {
return back()->withErrors(['product_id' => __('The selected product does not belong to this machine\'s company.')]);
}
}
// 處理過期時間:優先使用直接傳入的日期,否則使用時數計算
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
: now()->addHours((int) ($request->expires_hours ?? 24));
$usageLimit = $validated['usage_limit'] ?? 1;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
// 沿用單筆的綁定方式(優先 product_id相容舊的 slot_no
if ($quantity > 1) {
$batchNo = 'PUB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PickupCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'expires_at' => $expiresAt,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PickupCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PickupCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
],
]);
return back()
->with('success', __(':count pickup codes generated', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pickup',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pickup-codes.batch-download', $batchNo),
]);
}
$pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'],
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'slot_no' => $validated['slot_no'],
'code' => $request->custom_code ?? PickupCode::generateUniqueCode($validated['machine_id']),
'usage_limit' => $validated['usage_limit'] ?? 1,
'usage_count' => 0,
@ -700,30 +326,6 @@ class SalesController extends Controller
return back()->with('success', __('Pickup code generated: :code', ['code' => $pickupCode->code]));
}
/**
* 產碼用 AJAX回傳機台所屬「公司」的商品清單以及機台目前已上架的 product_id。
* 商品下拉以公司商品為準(非僅機台貨道);前端用 machine_product_ids 標示「此機台未上架」提示。
*/
public function pickupCompanyProducts(Machine $machine)
{
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->select('id', 'name')
->orderBy('name')
->get();
$machineProductIds = $machine->slots()
->whereNotNull('product_id')
->pluck('product_id')
->unique()
->values();
return response()->json([
'success' => true,
'products' => $products,
'machine_product_ids' => $machineProductIds,
]);
}
/**
* 更新取貨碼 (僅限修改時間)
*/
@ -760,11 +362,6 @@ class SalesController extends Controller
*/
public function destroyPickupCode(PickupCode $pickupCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該取貨碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($pickupCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $pickupCode->toArray();
$pickupCode->update(['status' => 'cancelled']);
@ -812,17 +409,9 @@ class SalesController extends Controller
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PassCode::with(['machine', 'creator'])
->whereHas('machine')
->latest();
$query = PassCode::with(['machine', 'creator'])->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -858,9 +447,7 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料pass_code_logs.machine_id 為 NOT NULL
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode'])
->whereHas('machine');
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode']);
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -926,87 +513,22 @@ class SalesController extends Controller
*/
public function storePassCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆(後端強制把關)。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:50',
'expires_days' => 'nullable|integer|min:0',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
'custom_code' => 'required|string|min:4|max:12',
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
$expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null;
// 勾「只能使用一次」→ 使用上限 1未勾 → null(無限次,維持原行為)。
$usageLimit = $request->boolean('single_use') ? 1 : null;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
if ($quantity > 1) {
$batchNo = 'PSB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PassCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $i => $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'name' => $validated['name'] . ' #' . ($i + 1),
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PassCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PassCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'name' => $validated['name'],
],
]);
return back()
->with('success', __(':count pass codes created', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pass',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pass-codes.batch-download', $batchNo),
]);
}
$passCode = PassCode::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'] ?? 'Manual Generate',
'code' => $request->custom_code ?: PassCode::generateUniqueCode($validated['machine_id']),
'code' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
@ -1024,125 +546,6 @@ class SalesController extends Controller
return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code]));
}
/**
* 批次產生指定數量的唯一 8 位數字碼(避開該機台目前有效的碼,並去除同批重複)
*
* @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass PickupCode::class PassCode::class
* @return string[]
*/
private function generateUniqueCodeBatch(string $modelClass, int $machineId, int $quantity): array
{
// 載入該機台目前仍有效的碼,避免與既有碼衝突
$existing = array_flip(
$modelClass::where('machine_id', $machineId)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->pluck('code')
->all()
);
$codes = [];
$seen = [];
while (count($codes) < $quantity) {
$code = str_pad((string) random_int(0, 99999999), 8, '0', STR_PAD_LEFT);
if (isset($existing[$code]) || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$codes[] = $code;
}
return $codes;
}
/**
* 下載某一批取貨碼清單 (CSV)
*/
public function downloadPickupCodeBatch(string $batchNo)
{
$codes = PickupCode::with(['machine:id,name,serial_no', 'product:id,name'])
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pickup Code'),
__('Target Machine'),
__('Select Product'),
__('Select Slot'),
__('Usage Limit'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟才不會亂碼
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->product)->name ?? '',
$c->slot_no ?? '',
$c->usage_limit,
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pickup-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 下載某一批通行碼清單 (CSV)
*/
public function downloadPassCodeBatch(string $batchNo)
{
$codes = PassCode::with('machine:id,name,serial_no')
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pass Code'),
__('Description / Name'),
__('Target Machine'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
$c->name,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pass-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 更新通行碼
*/
@ -1184,11 +587,6 @@ class SalesController extends Controller
*/
public function destroyPassCode(PassCode $passCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該通行碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($passCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $passCode->toArray();
$passCode->update(['status' => 'disabled']);
@ -1220,10 +618,7 @@ class SalesController extends Controller
// 1. 來店禮列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的來店禮(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = WelcomeGift::with(['machine', 'creator'])
->whereHas('machine')
->latest();
$query = WelcomeGift::with(['machine', 'creator'])->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -1259,9 +654,7 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料welcome_gift_logs.machine_id 為 NOT NULL
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no'])
->whereHas('machine');
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no']);
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -1453,11 +846,6 @@ class SalesController extends Controller
*/
public function destroyWelcomeGift(WelcomeGift $welcomeGift)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該來店禮所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($welcomeGift->machine_id)->exists()) {
abort(403);
}
$oldValues = $welcomeGift->toArray();
$welcomeGift->update(['status' => 'disabled']);
@ -1547,9 +935,6 @@ class SalesController extends Controller
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
'awaiting_pickup' => __('Awaiting Pickup'),
'failed' => __('Failed'),
'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
@ -1643,7 +1028,7 @@ class SalesController extends Controller
$row = [
$inv->invoice_no ?: '---',
$inv->invoice_date ?: '---',
$inv->display_flow_id ?: '---',
$inv->flow_id ?: '---',
$inv->machine->name ?? ($inv->order->machine->name ?? 'Unknown'),
$inv->machine->serial_no ?? ($inv->order->machine->serial_no ?? '---'),
$inv->order->order_no ?? '---',

View File

@ -110,15 +110,6 @@ class StaffCardController extends Controller
$validated['status'] = $request->get('status', $staffCard->status);
// 寫入安全company_id 不得由前端任意決定或被洗成 null
// - 非系統管理員:強制綁定自身公司 (租戶隔離)
// - 系統管理員:若未帶入公司,保留原本歸屬,避免變成無公司歸屬的孤兒卡 (機台將查無此卡)
if (!auth()->user()->isSystemAdmin()) {
$validated['company_id'] = auth()->user()->company_id;
} elseif (empty($validated['company_id'])) {
$validated['company_id'] = $staffCard->company_id;
}
$staffCard->update($validated);
if ($request->ajax()) {

View File

@ -722,206 +722,10 @@ class WarehouseController extends Controller
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
// 供批次匯出 modal 勾選用的完整機台清單(全域 scope 已限可存取機台)
$exportMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
return view('admin.warehouses.machine-inventory', compact('machines', 'companies', 'exportMachines'));
return view('admin.warehouses.machine-inventory', compact('machines', 'companies'));
}
/**
* 匯出單一機台的「每個貨道」明細CSV / Excel——供機台詳細視圖(矩陣/表格)旁的匯出鈕使用。
* 參考 ProductController::export / SalesController::handleExport 的串流下載模式。
*/
public function machineInventoryExport(Request $request, Machine $machine)
{
$type = $request->input('export', 'csv');
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
$slots = $machine->slots()
->with('product:id,name,barcode')
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
$safeSerial = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $machine->serial_no);
$filename = '貨道明細_' . ($safeSerial ?: $machine->id) . '_' . now()->format('YmdHis') . '.' . $ext;
$callback = function () use ($slots, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
fputcsv($file, $headers);
}
foreach ($slots as $slot) {
$stock = (int) $slot->stock;
$capacity = (int) $slot->max_stock;
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
$row = [
$slot->slot_no,
$slot->product_id ?? '',
$slot->product->name ?? '',
$slot->product->barcode ?? '',
$slot->type,
$stock,
$capacity,
$rate,
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
$slot->batch_no,
$slot->is_active ? '是' : '否',
$slot->is_locked ? '是' : '否',
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $cell) {
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
}
/**
* 批次匯出「機台庫存概覽」——每台機台一個分頁(Excel sheet)/一個檔(CSV),內容為該機台每貨道明細。
* Excel = 多分頁 xlsx(OpenSpout)CSV 無分頁概念,故每台一個 .csv 打包成 ZIP。
* ids逗號字串或陣列空則匯出全部可存取機台。全域 scope 確保只含可存取機台。
*/
public function machineInventoryExportBatch(Request $request)
{
$isExcel = ($request->input('export', 'csv') === 'excel');
$ids = $request->input('ids');
if (is_string($ids)) {
$ids = array_filter(array_map('intval', explode(',', $ids)));
} elseif (is_array($ids)) {
$ids = array_filter(array_map('intval', $ids));
} else {
$ids = [];
}
$query = Machine::with(['slots' => fn ($q) => $q->with('product:id,name,barcode')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
->orderBy('name');
if (!empty($ids)) {
$query->whereIn('id', $ids);
}
$machines = $query->get();
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
$ts = now()->format('YmdHis');
// 單台的資料列
$rowsOf = function ($machine) {
$rows = [];
foreach ($machine->slots as $slot) {
$stock = (int) $slot->stock;
$capacity = (int) $slot->max_stock;
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
$rows[] = [
(string) $slot->slot_no,
$slot->product_id ?? '',
$slot->product->name ?? '',
$slot->product->barcode ?? '',
(string) $slot->type,
$stock,
$capacity,
$rate,
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
(string) $slot->batch_no,
$slot->is_active ? '是' : '否',
$slot->is_locked ? '是' : '否',
];
}
return $rows;
};
// 分頁/檔名機台名稱_序號去非法字元、限長、去重Excel sheet 名上限 31 字)
$labelOf = function ($machine, array &$used) {
$b = trim(($machine->name ?? '機台') . '_' . ($machine->serial_no ?? $machine->id));
$b = preg_replace('/[\\\\\\/\\?\\*\\[\\]:]/u', '_', $b);
$b = mb_substr($b, 0, 28);
$name = $b;
$i = 1;
while (in_array($name, $used, true)) {
$name = mb_substr($b, 0, 24) . '_' . (++$i);
}
$used[] = $name;
return $name;
};
if ($isExcel) {
$tmp = tempnam(sys_get_temp_dir(), 'invxlsx');
$writer = new \OpenSpout\Writer\XLSX\Writer();
$writer->openToFile($tmp);
$used = [];
$first = true;
foreach ($machines as $machine) {
$sheet = $first ? $writer->getCurrentSheet() : $writer->addNewSheetAndMakeItCurrent();
$first = false;
$sheet->setName($labelOf($machine, $used));
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
foreach ($rowsOf($machine) as $row) {
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($row));
}
}
if ($first) {
$writer->getCurrentSheet()->setName('機台庫存');
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
}
$writer->close();
return response()->download($tmp, "機台庫存_{$ts}.xlsx", [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
])->deleteFileAfterSend(true);
}
// CSV每台一個 .csv 打包成 ZIP
$tmp = tempnam(sys_get_temp_dir(), 'invzip');
$zip = new \ZipArchive();
$zip->open($tmp, \ZipArchive::OVERWRITE);
$used = [];
foreach ($machines as $machine) {
$fh = fopen('php://temp', 'r+');
fputcsv($fh, $headers);
foreach ($rowsOf($machine) as $row) {
fputcsv($fh, $row);
}
rewind($fh);
$content = "\xEF\xBB\xBF" . stream_get_contents($fh);
fclose($fh);
$zip->addFromString($labelOf($machine, $used) . '.csv', $content);
}
if ($machines->isEmpty()) {
$zip->addFromString('機台庫存.csv', "\xEF\xBB\xBF" . implode(',', $headers) . "\n");
}
$zip->close();
return response()->download($tmp, "機台庫存_{$ts}.zip")->deleteFileAfterSend(true);
}
/**
* AJAX取得單台機台貨道詳情
*/
@ -1036,9 +840,10 @@ class WarehouseController extends Controller
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']);
$products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser);
if ($isSystemAdmin && $companyId !== '') {
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name');
if (!$isSystemAdmin) {
$userQuery->where('company_id', $currentUser->company_id);
} elseif ($companyId !== '') {
$userQuery->where('company_id', $companyId);
}
$users = $userQuery->get(['id', 'name']);
@ -1072,10 +877,7 @@ class WarehouseController extends Controller
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]);
// 補貨針對機台,單據歸屬以「機台所綁公司」為準(系統管理員可為他公司機台建單)
$machine = Machine::findOrFail($validated['machine_id']);
DB::transaction(function () use ($validated, $machine) {
DB::transaction(function () use ($validated) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
@ -1087,10 +889,10 @@ class WarehouseController extends Controller
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => $machine->company_id,
'company_id' => Auth::user()->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $machine->id,
'machine_id' => $validated['machine_id'],
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
@ -1209,7 +1011,7 @@ class WarehouseController extends Controller
}
// 有 items = 確認建單
DB::transaction(function () use ($validated, $machine) {
DB::transaction(function () use ($validated) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
@ -1221,10 +1023,10 @@ class WarehouseController extends Controller
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => $machine->company_id,
'company_id' => Auth::user()->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $machine->id,
'machine_id' => $validated['machine_id'],
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? __('Auto Replenishment'),
'created_by' => Auth::id(),

View File

@ -371,12 +371,6 @@ class MachineController extends Controller
'product_id' => $item['t060v00'] ?? null,
'stock' => isset($item['num']) ? (int) $item['num'] : 0,
'type' => isset($item['type']) ? (int) $item['type'] : null,
// 雙向 LWW鎖定與效期/批號 + 各自的編輯序號App 端 store 編輯才遞增)
'is_locked' => isset($item['is_locked']) ? (bool) $item['is_locked'] : false,
'lock_rev' => isset($item['lock_rev']) ? (int) $item['lock_rev'] : 0,
'expiry_date' => $item['expiry_date'] ?? null,
'batch_no' => $item['batch_no'] ?? null,
'expiry_rev' => isset($item['expiry_rev']) ? (int) $item['expiry_rev'] : 0,
];
}, $legacyData);
@ -400,8 +394,8 @@ class MachineController extends Controller
{
$machine = $request->get('machine');
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload
$payload = $catalogService->getMachinePayload($machine);
// Cache First: Get from cache or rebuild if missing
$payload = $catalogService->getPayload($machine->company_id);
return response()->json($payload);
}
@ -508,7 +502,6 @@ class MachineController extends Controller
'MemberSystem' => (bool) ($s['member_system_enabled'] ?? false), // 會員系統
'AmbientTemp' => (bool) ($s['ambient_temp_monitoring_enabled'] ?? false), // 環境溫度監控
'PharmacyPickup' => (bool) ($s['pharmacy_pickup_enabled'] ?? false), // 領藥單(雲端建單,取物單模式下開關)
'Subcabinet' => (bool) ($s['subcabinet_enabled'] ?? false), // 副櫃系統(格子櫃功能,基礎版授權開關)
];
// 5-4 ShoppingMode購物方式頂層字串
@ -701,10 +694,9 @@ class MachineController extends Controller
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
$data = [
'slot_no' => $pickupCode->slot_no, // 保留:既有綁貨道碼讀此欄(綁商品碼/領藥單為 null
'product_id' => $pickupCode->product_id, // 綁商品碼App 據此自挑可出貨道(綁貨道碼為 null
'slot_no' => $pickupCode->slot_no, // 保留:既有單貨道機台讀此欄(領藥單為 null
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => $pickupCode->status,
];
@ -722,86 +714,23 @@ class MachineController extends Controller
return response()->json(['success' => false, 'message' => 'Pharmacy pickup not enabled on this machine', 'code' => 403], 403);
}
$pharmacyService = app(\App\Services\Transaction\PharmacyPickupService::class);
// 嚴格擋關:任一商品的貨道可出量 < 領藥單需求量 → 整單擋下、不出貨、不消耗領藥碼,回明確提示
$shortage = $pharmacyService->dispenseShortage($order);
if (!empty($shortage)) {
$detail = collect($shortage)
->map(fn ($s) => "{$s['product_name']}(需 {$s['required']}、可出 {$s['available']}")
->implode('、');
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_insufficient_stock',
'raw_data' => ['order_no' => $order->order_no, 'shortage' => $shortage],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '因貨道庫存不足,無法出貨:' . $detail,
], 409);
}
// 依目前貨道庫存解析多貨道出貨清單(一商品可跨多貨道湊量)
$items = $pharmacyService->resolveDispenseItems($order);
$items = app(\App\Services\Transaction\PharmacyPickupService::class)->resolveDispenseItems($order);
// 解析不到可出貨貨道(機台無對應貨道/庫存不足)→ 不消耗領藥碼,回明確錯誤讓藥師處理
if (empty($items)) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_no_slot',
'raw_data' => ['order_no' => $order->order_no, 'reason' => 'no available slot/stock'],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此領藥單在本機台無可出貨貨道(請確認貨道對應與庫存)',
], 409);
// 標記碼已領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';
}
// 不在「驗證/掃碼」當下核銷領藥碼。核銷統一改由機台實際出貨後的
// MQTT finalizeTransactionService::finalizePharmacyDispense標記為 used
// 這樣顧客掃碼後若未按「確定取貨」(逾時/取消、未出貨),領藥碼仍維持 active 可再次掃碼領取。
// (原本在此處 usage_count++/轉 used 會在尚未取貨時就把碼鎖死,導致再掃顯示「已領」。)
$pickupCode->save();
$data['order_type'] = \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
$data['order_no'] = $order->order_no;
$data['pricing_slip_no'] = $order->pricing_slip_no ?? ''; // 批價單號(藥師批價依據);機台「確認領藥」畫面備註欄顯示
$data['items'] = $items; // [{slot_no, product_id, product_name, qty}, ...]
$data['status'] = $pickupCode->status;
}
// === 綁商品取貨碼(非領藥單)擋關:機台須仍有此商品貨道,否則 App 必然挑不到貨道 ===
// 即時庫存/鎖定/效期由 App 刷碼當下自行判斷;此處只擋「機台根本沒上這商品」的明確錯誤。
if (!$isPharmacy && $pickupCode->product_id) {
$hasProduct = $machine->slots()->where('product_id', $pickupCode->product_id)->exists();
if (!$hasProduct) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.no_slot_for_product',
'raw_data' => ['code' => $pickupCode->code, 'product_id' => $pickupCode->product_id],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此機台目前無此商品貨道,無法取貨',
], 409);
}
}
return response()->json([
'success' => true,
'code' => 200,
@ -852,17 +781,6 @@ class MachineController extends Controller
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
]);
// 單次/限次通行碼:驗證成功即計為使用一次;達上限就標記 used
// 下次驗證會被上方 where('status','active') 過濾擋掉,達成「只能使用一次」。
// usage_limit 為 null未勾單次時不計數維持原本無限次行為。
if (!is_null($passCode->usage_limit)) {
$newCount = $passCode->usage_count + 1;
$passCode->update([
'usage_count' => $newCount,
'status' => $newCount >= $passCode->usage_limit ? 'used' : $passCode->status,
]);
}
return response()->json([
'success' => true,

View File

@ -13,7 +13,7 @@ class PickupController extends Controller
*/
public function show($slug)
{
$pickupCode = PickupCode::with(['machine.slots.product', 'product'])
$pickupCode = PickupCode::with(['machine.slots.product'])
->where(function($query) use ($slug) {
$query->where('slug', $slug)
->orWhere('code', $slug); // 相容舊版或測試

View File

@ -147,7 +147,7 @@ class ProcessCommandAck implements ShouldQueue
]);
// 記錄維護類指令到機台日誌 (MachineLog)
if (in_array($command->command_type, ['reboot', 'reboot_force', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
$level = $status === 'success' ? 'info' : 'warning';

View File

@ -54,14 +54,6 @@ class Machine extends Model
])) {
app(\App\Services\Machine\MachineService::class)->syncMachineSlotMaxStock($machine);
}
// 機台轉移公司:不同公司商品 ID 體系不同,舊的機台專屬定價必須清空,
// 避免跨租戶價格錯位與資料污染。批次刪除不觸發 model 事件,故補推一次
// B012 同步,讓機台重抓新公司目錄與(已清空的)定價。
if ($machine->wasChanged('company_id')) {
$machine->productPrices()->delete();
\App\Jobs\Product\SendProductSyncCommandJob::dispatch($machine->id, __('Machine company changed'));
}
});
}
@ -409,11 +401,6 @@ class Machine extends Model
return $this->hasMany(MachineSlot::class);
}
public function productPrices()
{
return $this->hasMany(MachineProductPrice::class);
}
public function commands()
{
return $this->hasMany(RemoteCommand::class);

View File

@ -1,58 +0,0 @@
<?php
namespace App\Models\Machine;
use App\Jobs\Product\SendProductSyncCommandJob;
use App\Models\Product\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* 機台專屬定價:特定機台 × 特定商品的價格覆蓋。
*
* - price / member_price NULL 代表沿用全域 products 價。
* - 任何新增/修改/刪除都會推送 B012 同步指令給「該台」機台,使其即時重抓目錄。
* 目錄覆蓋於 B012 請求當下即時套用(見 ProductCatalogService::getMachinePayload
* 不進公司目錄快取,故改價不需失效公司快取。
*/
class MachineProductPrice extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'product_id',
'price',
'member_price',
];
protected $casts = [
'price' => 'decimal:2',
'member_price' => 'decimal:2',
];
protected static function booted(): void
{
static::saved(fn (self $row) => $row->dispatchSync());
static::deleted(fn (self $row) => $row->dispatchSync());
}
/**
* 推送 B012 同步指令給該機台,讓它即時重抓最新定價。
* 控制台/離線判定沿用 SendProductSyncCommandJob 既有邏輯。
*/
protected function dispatchSync(): void
{
SendProductSyncCommandJob::dispatch($this->machine_id, __('Machine pricing updated'));
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@ -20,19 +20,12 @@ class MachineSlot extends Model
'expiry_date',
'batch_no',
'is_active',
'is_locked',
'last_app_lock_rev',
'last_app_expiry_rev',
];
protected $casts = [
'price' => 'decimal:2',
'last_restocked_at' => 'datetime',
'expiry_date' => 'date:Y-m-d',
'is_active' => 'boolean',
'is_locked' => 'boolean',
'last_app_lock_rev' => 'integer',
'last_app_expiry_rev' => 'integer',
];
public function machine()

View File

@ -10,14 +10,11 @@ class Role extends SpatieRole
'name',
'guard_name',
'company_id',
'created_by',
'is_system',
'is_company_admin',
];
protected $casts = [
'is_system' => 'boolean',
'is_company_admin' => 'boolean',
];
/**
@ -28,14 +25,6 @@ class Role extends SpatieRole
return $this->belongsTo(Company::class);
}
/**
* 建立者帳號。
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Scope a query to only include roles for a specific company or system roles.
*/
@ -46,43 +35,4 @@ class Role extends SpatieRole
->orWhereNull('company_id');
});
}
/**
* 查詢範圍:限制為指定使用者「可見」的角色(鏡像 User::scopeVisibleTo
* - 系統管理員:全部
* - 主帳號:同公司全部
* - 子帳號:僅自己建立的角色
*/
public function scopeVisibleTo($query, User $viewer)
{
if ($viewer->isSystemAdmin()) {
return $query;
}
$query->where($this->getTable() . '.company_id', $viewer->company_id);
if (!$viewer->is_admin) {
$query->where($this->getTable() . '.created_by', $viewer->id);
}
return $query;
}
/**
* 操作者是否有權管理(編輯/刪除)此角色。
* - 系統管理員:全部
* - 跨公司:禁止
* - 主帳號:同公司全部
* - 子帳號:僅自己建立的角色
*/
public function canBeManagedBy(User $user): bool
{
if ($user->isSystemAdmin()) {
return true;
}
if ($this->company_id !== $user->company_id) {
return false;
}
if ($user->is_admin) {
return true;
}
return $this->created_by === $user->id;
}
}

View File

@ -23,8 +23,6 @@ class User extends Authenticatable
*/
protected $fillable = [
'company_id',
'parent_id',
'level',
'username',
'name',
'email',
@ -55,14 +53,8 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
'level' => 'integer',
];
/**
* 帳號層級上限:子帳號最多兩層(主帳號 level 0 level 1 level 2level 2 不可再建)。
*/
public const MAX_LEVEL = 2;
/**
* Get the login logs for the user.
*/
@ -103,85 +95,6 @@ class User extends Authenticatable
return !is_null($this->company_id);
}
/**
* 上層(建立者)帳號。
*/
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
/**
* 直接建立的下層帳號。
*/
public function children()
{
return $this->hasMany(self::class, 'parent_id');
}
/**
* 是否為該公司主帳號(租戶層級且 is_admin
*/
public function isMainAccount(): bool
{
return $this->isTenant() && $this->is_admin;
}
/**
* 是否為子帳號(租戶層級且非主帳號)。
*/
public function isSubAccount(): bool
{
return $this->isTenant() && !$this->is_admin;
}
/**
* 是否還能再建立下層子帳號(深度上限約束,系統管理員不受限)。
*/
public function canCreateSubAccount(): bool
{
return $this->isSystemAdmin() || $this->level < self::MAX_LEVEL;
}
/**
* 查詢範圍:限制為指定使用者「可見」的帳號。
* - 系統管理員:全部
* - 主帳號:同公司全部(安全網)
* - 子帳號:僅自己直接建立的下層帳號
*/
public function scopeVisibleTo($query, self $viewer)
{
if ($viewer->isSystemAdmin()) {
return $query;
}
$query->where($this->getTable() . '.company_id', $viewer->company_id);
if (!$viewer->is_admin) {
$query->where($this->getTable() . '.parent_id', $viewer->id);
}
return $query;
}
/**
* 操作者是否有權管理(編輯/刪除/停用)指定帳號。
* - 系統管理員:全部
* - 跨公司:禁止
* - 主帳號:同公司全部
* - 子帳號:僅自己直接建立的下層帳號
*/
public function canManageAccount(self $target): bool
{
if ($this->isSystemAdmin()) {
return true;
}
if ($target->company_id !== $this->company_id) {
return false;
}
if ($this->is_admin) {
return true;
}
return $target->parent_id === $this->id;
}
/**
* Get the URL for the user's avatar.
*/

View File

@ -6,14 +6,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
use App\Traits\HasDisplayFlowId;
class Invoice extends Model
{
// TenantScoped自動以登入者 company_id 過濾(含 route-model binding 與列表查詢),
// 防止跨公司讀取/操作他人發票。console佇列 Job / reconcile 命令)會自動跳過 scope
// 系統身分仍可跨公司開立/對帳。
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',

View File

@ -6,18 +6,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
use App\Traits\HasDisplayFlowId;
use App\Models\Machine\Machine;
use App\Models\Member\Member;
class Order extends Model
{
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
use HasFactory, SoftDeletes, TenantScoped;
// 訂單類型 (order_type)
public const TYPE_SALE = 'sale'; // 一般銷售
public const TYPE_PHARMACY_PICKUP = 'pharmacy_pickup'; // 領藥單
public const TYPE_MANUAL = 'manual'; // 手動補單(後台人工補登,機台斷線漏報時使用)
// 支付狀態
public const PAYMENT_STATUS_FAILED = 0;
@ -60,7 +58,6 @@ class Order extends Model
'discount_amount',
'pay_amount',
'change_amount',
'cash_detail',
'points_used',
'original_amount',
'payment_status',
@ -83,7 +80,6 @@ class Order extends Model
'discount_amount' => 'decimal:2',
'pay_amount' => 'decimal:2',
'change_amount' => 'decimal:2',
'cash_detail' => 'array',
'original_amount' => 'decimal:2',
'payment_at' => 'datetime',
'machine_time' => 'datetime',
@ -92,42 +88,6 @@ class Order extends Model
'delivery_status' => 'integer',
];
/**
* 現金收款摘要字串:「收:{總收金額}(只列有收到的面額明細) 找:{找零}」。
* 120:1、10:2 30。無 cash_detail非現金或舊資料或全為 0 時回 null
* 總收金額由各面額張數加總得出,與括號內明細一致。
*/
public function getCashReceivedSummaryAttribute(): ?string
{
$cd = $this->cash_detail;
if (empty($cd)) {
return null;
}
// 面額顯示標籤 + 幣值(依大到小)
$denoms = [
'b1000' => ['label' => '仟', 'unit' => 1000],
'b500' => ['label' => '伍佰', 'unit' => 500],
'b100' => ['label' => '佰', 'unit' => 100],
'c50' => ['label' => '50', 'unit' => 50],
'c10' => ['label' => '10', 'unit' => 10],
'c5' => ['label' => '5', 'unit' => 5],
'c1' => ['label' => '1', 'unit' => 1],
];
$received = 0;
$parts = [];
foreach ($denoms as $key => $d) {
$n = (int) ($cd[$key] ?? 0);
if ($n > 0) {
$parts[] = $d['label'] . ':' . $n; // 只列有收到的幣別
$received += $n * $d['unit'];
}
}
if (empty($parts)) {
return null;
}
return '收:' . $received . '' . implode('、', $parts) . ') 找:' . number_format($this->change_amount, 0);
}
/**
* 取得所有支付類型的對照標籤
*/
@ -243,12 +203,6 @@ class Order extends Model
return $query->where('order_type', self::TYPE_PHARMACY_PICKUP);
}
/** 是否為後台手動補單 */
public function getIsManualAttribute(): bool
{
return $this->order_type === self::TYPE_MANUAL;
}
public function machine()
{
return $this->belongsTo(Machine::class);
@ -308,25 +262,11 @@ class Order extends Model
];
}
/**
* 出貨狀態是否有意義:唯有付款成功(completed/paid)、機台真的動作過的單才有出貨結果。
* pending(未完成)、failed(支付失敗)、abandoned(未支付)從未出貨,出貨狀態應顯示 '--'
* delivery_status 欄位 DB 預設為 1(出貨成功),這些單若直接讀值會誤顯示成「出貨成功」。
*/
public function hasDeliveryOutcome(): bool
{
return in_array($this->status, [self::STATUS_COMPLETED, 'paid'], true);
}
/**
* 取得目前出貨狀態標籤 (Accessor)
*/
public function getDeliveryStatusLabelAttribute(): string
{
if (! $this->hasDeliveryOutcome()) {
return '--';
}
return self::getDeliveryStatusLabels()[$this->delivery_status] ?? __('Unknown');
}
}

View File

@ -14,7 +14,6 @@ class OrderItem extends Model
'order_id',
'product_id',
'product_name',
'slot_no',
'barcode',
'price',
'quantity',

View File

@ -18,11 +18,8 @@ class PassCode extends Model
'name',
'code',
'slug',
'batch_no',
'expires_at',
'status',
'usage_limit', // 可用次數上限null=無限次。勾「只能使用一次」時=1
'usage_count', // 已使用次數
'created_by',
];
@ -79,11 +76,6 @@ class PassCode extends Model
return false;
}
// 有設使用次數上限(如「只能使用一次」usage_limit=1)且已用完 → 失效。null=無限次。
if (!is_null($this->usage_limit) && $this->usage_count >= $this->usage_limit) {
return false;
}
return true;
}

View File

@ -15,11 +15,9 @@ class PickupCode extends Model
protected $fillable = [
'company_id',
'machine_id',
'product_id',
'slot_no',
'code',
'slug',
'batch_no',
'status',
'expires_at',
'used_at',
@ -60,14 +58,6 @@ class PickupCode extends Model
return $this->belongsTo(Machine::class);
}
/**
* 關聯商品(取貨碼改綁商品時使用)
*/
public function product(): BelongsTo
{
return $this->belongsTo(\App\Models\Product\Product::class);
}
/**
* 關聯建立者
*/

View File

@ -338,31 +338,17 @@ class MachineService
'is_active' => true,
];
// ── 雙向 LWW鎖定(lock) 與 效期/批號(expiry/batch),各自獨立 rev ──
$updateData = array_merge(
$updateData,
$this->resolveSlotLockLww($existingSlot, $slotData),
$this->resolveSlotExpiryLww($existingSlot, $slotData, $actualProductId)
);
if ($existingSlot) {
$oldStock = (int) $existingSlot->stock;
$oldProductId = $existingSlot->product_id;
// 檢查是否有實質變動 (Check for actual changes)
$isStockChanged = ($oldStock !== $newStock);
$isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況
$isSlotConfigChanged = (string) $existingSlot->type !== (string) $updateData['type']
|| (int) $existingSlot->max_stock !== (int) $updateData['max_stock'];
// lock/expiry/rev 也算實質變動,確保 LWW 結果會被寫回(即使 stock/product 沒變)
$curExpiryStr = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
$isLwwChanged = ((bool) $existingSlot->is_locked !== (bool) $updateData['is_locked'])
|| ((int) $existingSlot->last_app_lock_rev !== (int) $updateData['last_app_lock_rev'])
|| ((int) $existingSlot->last_app_expiry_rev !== (int) $updateData['last_app_expiry_rev'])
|| ($curExpiryStr !== $updateData['expiry_date'])
|| ($existingSlot->batch_no !== $updateData['batch_no']);
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged || $isLwwChanged) {
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged) {
$existingSlot->update($updateData);
$existingSlot->refresh();
@ -406,12 +392,10 @@ class MachineService
->values()
->toArray();
// 防呆:上報清單為空時不執行全量刪除(避免機台/APP 誤報空清單而把所有貨道清光)。
$orphanedSlots = empty($reportedSlotNos)
? collect()
: $machine->slots()
->whereNotIn('slot_no', $reportedSlotNos)
->get();
// 找出資料庫中屬於此機台但不在清單內的舊貨道
$orphanedSlots = $machine->slots()
->whereNotIn('slot_no', $reportedSlotNos)
->get();
foreach ($orphanedSlots as $orphan) {
$oldStock = (int) $orphan->stock;
@ -436,88 +420,6 @@ class MachineService
});
}
/**
* 空字串/"null" 一律歸一化為 null(避免 MySQL date 欄位寫入空字串解析爆)。
*/
private function blankToNull($value)
{
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null || (is_string($value) && strtolower($value) === 'null')) {
return null;
}
return $value;
}
/**
* 取日期字串的 yyyy-MM-dd 部分(容錯後台/機台帶了時間),空值回 null
*/
private function normalizeDate($value)
{
$value = $this->blankToNull($value);
if ($value === null) {
return null;
}
return substr((string) $value, 0, 10);
}
/**
* 貨道鎖定(lock)的雙向 LWW 解析,回傳要寫入的 is_locked / last_app_lock_rev。
* - 新貨道 App 現場值。
* - 既有貨道 常態 LWWApp rev 較大才採用,否則保留後台值。
* 既有機台升級時App onUpgrade 已把現場既有鎖定的 lock_rev 設為 1,故能 1>0 自動遷移上雲。)
*/
private function resolveSlotLockLww(?MachineSlot $existingSlot, array $slotData): array
{
$appLocked = (bool) ($slotData['is_locked'] ?? false);
$appRev = (int) ($slotData['lock_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appLocked ? 1 : 0);
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $newRev];
}
$curLocked = (bool) $existingSlot->is_locked;
$curRev = (int) $existingSlot->last_app_lock_rev;
if ($appRev > $curRev) {
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $appRev];
}
return ['is_locked' => $curLocked, 'last_app_lock_rev' => $curRev];
}
/**
* 效期/批號(expiry/batch)的雙向 LWW 解析,回傳要寫入的 expiry_date / batch_no / last_app_expiry_rev。
* 換商品(product_id 變更)時預設清空舊效期(安全網),除非 App 同筆帶了較新的新效期。
*/
private function resolveSlotExpiryLww(?MachineSlot $existingSlot, array $slotData, $actualProductId): array
{
$appExpiry = $this->normalizeDate($slotData['expiry_date'] ?? null);
$appBatch = $this->blankToNull($slotData['batch_no'] ?? null);
$appRev = (int) ($slotData['expiry_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appExpiry !== null ? 1 : 0);
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $newRev];
}
$curExpiry = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
$curBatch = $this->blankToNull($existingSlot->batch_no);
$curRev = (int) $existingSlot->last_app_expiry_rev;
// 換商品安全網:預設清空,除非 App 同筆帶了較新的新效期
$isProductChanged = ((string) ($existingSlot->product_id) !== (string) $actualProductId);
if ($isProductChanged) {
if ($appExpiry !== null && $appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => null, 'batch_no' => null, 'last_app_expiry_rev' => max($curRev, $appRev)];
}
if ($appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => $curExpiry, 'batch_no' => $curBatch, 'last_app_expiry_rev' => $curRev];
}
public function syncProductSlotMaxStock(Product $product): void
{
MachineSlot::query()
@ -606,55 +508,27 @@ class MachineService
->first();
if ($pendingCommand) {
// 重新下發時覆蓋前一筆尚未執行的指令。
// 若舊指令已超過 1 分鐘未收到機台回傳 (ACK),視為逾時 (timeout)
// 否則為使用者主動覆蓋 (superseded)。與 dispense 逾時邏輯一致。
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
// 直接覆蓋:將前一個尚未執行的指令標記為「已取代」
$pendingCommand->update([
'status' => $isTimedOut ? 'timeout' : 'superseded',
'note' => $isTimedOut
? __('Superseded by new command (Timeout)')
: __('Superseded by new command'),
'status' => 'superseded',
'note' => __('Superseded by new command'),
]);
}
// 紀錄舊數據以供回滾使用
$oldExpiry = $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null;
$oldData = [
'stock' => $slot->stock,
'expiry_date' => $oldExpiry,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
];
// 2. 執行樂觀更新 (Optimistic Update):只更新「明確帶上的欄位」,避免鎖定切換誤清效期
$updateData = [];
$normExpiry = $oldExpiry;
if (array_key_exists('expiry_date', $data)) {
$normExpiry = $this->normalizeDate($expiryDate);
$updateData['expiry_date'] = $normExpiry;
}
$normBatch = $slot->batch_no;
if (array_key_exists('batch_no', $data)) {
$normBatch = $this->blankToNull($batchNo);
$updateData['batch_no'] = $normBatch;
}
if ($stock !== null) $updateData['stock'] = (int) $stock;
if (array_key_exists('is_locked', $data)) {
$updateData['is_locked'] = (bool) $data['is_locked'];
}
// 後台編輯效期/批號或鎖定 → 遞增對應 rev成為權威值下行帶給 App 對齊 synced_rev
$expiryEdited = ($normExpiry !== $oldExpiry) || ($normBatch !== $slot->batch_no);
if ($expiryEdited) {
$updateData['last_app_expiry_rev'] = (int) $slot->last_app_expiry_rev + 1;
}
$lockEdited = array_key_exists('is_locked', $data) && ((bool) $data['is_locked'] !== (bool) $slot->is_locked);
if ($lockEdited) {
$updateData['last_app_lock_rev'] = (int) $slot->last_app_lock_rev + 1;
}
if (!empty($updateData)) $slot->update($updateData);
// 2. 執行樂觀更新 (Optimistic Update)
$updateData = [
'expiry_date' => $expiryDate,
'batch_no' => $batchNo,
];
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
if ($stock !== null && (int)$stock !== $oldData['stock']) {
@ -670,8 +544,6 @@ class MachineService
);
}
$slot->refresh();
// 3. 建立遠端指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
@ -682,23 +554,19 @@ class MachineService
'slot_no' => (string)$slotNo,
'old' => $oldData,
'new' => [
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
'expiry_date' => $expiryDate ?: null,
'batch_no' => $batchNo ?: null,
]
]
]);
// 4. 推播 MQTT(一律帶上目前權威的 is_locked 與雙 rev讓 App 端對齊 synced_rev
// 4. 推播 MQTT
$mqttPayload = [
'slot_no' => (string) $slotNo,
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
'lock_rev' => (int) $slot->last_app_lock_rev,
'expiry_rev' => (int) $slot->last_app_expiry_rev,
'slot_no' => $command->payload['slot_no'],
'stock' => $command->payload['new']['stock'],
'expiry_date' => $command->payload['new']['expiry_date'],
'batch_no' => $command->payload['new']['batch_no'] ?? null,
];
app(\App\Services\Machine\MqttService::class)->pushCommand(

View File

@ -2,8 +2,6 @@
namespace App\Services\Product;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineProductPrice;
use App\Models\Product\Product;
use App\Models\System\Company;
use Illuminate\Support\Facades\Cache;
@ -78,54 +76,6 @@ class ProductCatalogService
Cache::forget($this->getCacheKey($companyId));
}
/**
* 取得「該機台」的商品目錄:公司基底目錄(快取) + 機台專屬定價覆蓋。
*
* 設計取捨:重的 i18n 目錄留在 per-company 快取;機台覆蓋於請求當下即時套用,
* 不另設機台快取。覆蓋只是一次 (machine_id) 的索引查詢,極輕量;且改價僅推「該台」
* 重抓,不會造成多台群湧。如此完全避開「機台快取失效 / 公司基底變動連帶失效」的複雜度。
*
* @param Machine $machine
* @return array
*/
public function getMachinePayload(Machine $machine): array
{
$payload = $this->getPayload($machine->company_id);
$overrides = MachineProductPrice::where('machine_id', $machine->id)
->get(['product_id', 'price', 'member_price'])
->keyBy('product_id');
if ($overrides->isEmpty()) {
return $payload;
}
// PHP 陣列為值複製array_map 產生新陣列,不會污染 Cache 內的公司基底目錄。
$payload['data'] = array_map(function ($item) use ($overrides) {
$override = $overrides->get((int) ($item['t060v00'] ?? 0));
if (!$override) {
return $item;
}
// 機台售價t063v03=App machinePrice有覆蓋價就用否則維持全域售價基底。
$sellingPrice = $override->price !== null
? (float) $override->price
: (float) $item['t063v03'];
$item['t063v03'] = $sellingPrice;
// 機台會員價t060v30有覆蓋就用否則沿用全域會員價
// 一律夾為「不高於機台售價」,避免會員買得比一般人貴。
$memberPrice = $override->member_price !== null
? (float) $override->member_price
: (float) $item['t060v30'];
$item['t060v30'] = min($memberPrice, $sellingPrice);
return $item;
}, $payload['data']);
return $payload;
}
/**
* Map a Product model to the catalog format expected by machines.
*

View File

@ -25,61 +25,30 @@ 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) {
->map(function ($slots) {
$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, // 已生成領藥單預留量
'available' => (int) $slots->sum('stock'),
'slot_count' => $slots->count(),
'slots' => $slots->pluck('slot_no')->implode(', '),
];
@ -112,19 +81,15 @@ class PharmacyPickupService
$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)
// 該機台此商品跨貨道可用庫存(僅檢查,不預扣)
$available = (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([
@ -208,34 +173,6 @@ class PharmacyPickupService
});
}
/**
* 檢查一張領藥單在「目前機台貨道庫存」下是否有商品庫存不足(無法整單出貨)。
* 回傳不足清單:[['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'=>], ...]

View File

@ -71,8 +71,6 @@ class TransactionService
'discount_amount' => $data['discount_amount'] ?? 0,
'pay_amount' => $data['pay_amount'],
'change_amount' => $data['change_amount'] ?? 0,
// 現金面額明細(僅現金 payment_type=9 會帶;其餘為 null
'cash_detail' => $data['cash_detail'] ?? null,
'points_used' => $data['points_used'] ?? 0,
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
'payment_type' => (int) ($data['payment_type'] ?? 0),
@ -120,14 +118,7 @@ class TransactionService
'payment_request' => $data['payment_request'] ?? $order->payment_request,
'payment_response' => $data['payment_response'] ?? $order->payment_response,
'pay_amount' => $data['pay_amount'] ?? $order->pay_amount,
// 商品總額/原始金額completed finalize 才帶正確金額(pending 階段機台送 0)
// 須比照 pay_amount 在轉移時一併更新,否則後台銷售紀錄金額會停在建立時的 0。
'total_amount' => $data['total_amount'] ?? $order->total_amount,
'original_amount' => $data['original_amount'] ?? $order->original_amount,
'discount_amount' => $data['discount_amount'] ?? $order->discount_amount,
'change_amount' => $data['change_amount'] ?? $order->change_amount,
// 現金面額明細completed finalize 才帶pending 階段無投幣結果)
'cash_detail' => $data['cash_detail'] ?? $order->cash_detail,
'points_used' => $data['points_used'] ?? $order->points_used,
'invoice_info' => $data['invoice_info'] ?? $order->invoice_info,
'delivery_status' => isset($data['delivery_status'])
@ -162,8 +153,6 @@ class TransactionService
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $productName,
// 消費者選擇的貨道/格子號App items[].slot_no 帶入);不管出貨成功或失敗都記錄。
'slot_no' => $item['slot_no'] ?? null,
'barcode' => $item['barcode'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
@ -219,12 +208,10 @@ class TransactionService
}
}
// RelateNumber 冪等鍵App 未帶時由後台以 flow_id 推導(綠界上限 30 字、限英數)。
// flow_id 在 finalizeTransaction 入口已前綴機台序號(serial+rawFlowId)
// 故此處直接取用即可,輸出與線上既有 serial+rawFlowId 逐字等價,不可再重複加 serial。
// RelateNumber 冪等鍵App 未帶時由後台以 serial + flow_id 推導(綠界上限 30 字)
$relateNumber = $data['relate_number'] ?? null;
if (empty($relateNumber) && !empty($data['flow_id'])) {
$relateNumber = substr($data['flow_id'], 0, 30);
$relateNumber = substr($machine->serial_no . $data['flow_id'], 0, 30);
}
$attributes = [
@ -314,40 +301,18 @@ class TransactionService
// 執行實體庫存扣除 (真實交易化)
if ((int) ($data['dispense_status'] ?? 0) === 1) {
if ($slot) {
// 防呆:庫存已為 0或更低時不再扣減避免出現負庫存(-1)。
// 正常情況下 B660 已嚴格擋下庫存不足的領藥單;此處為下位機與後台庫存不同步時的最後防線。
if ((int) $slot->stock > 0) {
$slot->decrement('stock');
$slot->decrement('stock');
// 售完(庫存歸 0即清空效期與批號避免舊批貨效期殘留。
// 場景:日後管理員只補 stock 未改 expiry_date 時update_inventory 才不會
// 把過期日一起下發、把新批貨誤鎖成「暫不販售」。與機台端「賣完清效期」對稱。
// 純加法:僅在仍有殘留值時才寫,不影響既有扣庫存行為。
if ($slot->stock <= 0 && ($slot->expiry_date !== null || $slot->batch_no !== null)) {
$slot->update([
'expiry_date' => null,
'batch_no' => null,
]);
}
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
$this->machineService->recordStockMovement(
$slot,
-1,
$type,
$order ?? $record,
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
['order_id' => $order?->id, 'dispense_id' => $record->id]
);
} else {
\Log::warning("Dispense decrement skipped: slot stock already <= 0", [
'machine_id' => $machine->id,
'slot_no' => $slotNo,
'current_stock' => (int) $slot->stock,
'flow_id' => $data['flow_id'] ?? null,
]);
}
$this->machineService->recordStockMovement(
$slot,
-1,
$type,
$order ?? $record,
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
['order_id' => $order?->id, 'dispense_id' => $record->id]
);
} else {
// Log warning if slot matching fails to help diagnostic
\Log::warning("Dispense record created but slot not found for decrement", [
@ -368,30 +333,14 @@ class TransactionService
public function finalizeTransaction(array $data): Order
{
// 優先從 Root 讀取,再從 order 內讀取
$rawFlowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
if (empty($rawFlowId)) {
throw new \Exception("Flow ID is required for finalization");
}
$flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
// 跨機台防撞號App 端 flow_id 僅單機唯一(YYYYMMDDHHMMSSXXXX),加上機台序號前綴
// 才全域唯一(orders.flow_id 是全域 unique)。無分隔符——綠界 RelateNumber 禁特殊符號,
// 且 serial+flow_id 與線上既有 relateNumber 推導逐字等價(見 recordInvoice)。
// ACK 仍由 ProcessTransactionFinalized 回傳『原始』flow_idApp / outbox 對帳無感。
$flowId = $data['serial_no'] . $rawFlowId;
$data['flow_id'] = $flowId;
$data['order']['flow_id'] = $flowId;
return DB::transaction(function () use ($data, $flowId) {
$serialNo = $data['serial_no'];
// 冪等性 + 併發防護:終態檢查移進交易內並對既有 row 加排他鎖,
// 防止兩個 worker 同時穿透 pending 狀態而重複出貨/扣庫存/核銷。
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return行為不變。
// 既有單為 pending 時放行,往下交給 processTransaction 做 pending → paid/failed 轉移。
$existingOrder = Order::withoutGlobalScopes()
->where('flow_id', $flowId)
->lockForUpdate()
->first();
// 冪等性檢查:只有「終態(completed/failed/abandoned)」才直接回傳,避免重複出貨/扣庫存。
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return行為不變。
// 既有單為 pending本分支先送過 pending時則「放行」往下交給 processTransaction
// 做 pending → paid/failed 的狀態轉移(含本次的 dispense/invoice
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
Log::info("Transaction already finalized (terminal), returning existing order", [
'flow_id' => $flowId,
@ -400,30 +349,17 @@ class TransactionService
return $existingOrder;
}
// 領藥單(pharmacy_pickup):機台沿用中國醫(CMUH)出貨流程finalize 的 flow_id 是「機台本地流水號」,
// 但 order.order_no = 後端預建的領藥單號(RX…)。故優先以 order_no 對應預建領藥單,避免被當成新銷售單建立。
$pharmacyOrderNo = $data['order']['order_no'] ?? ($data['order_no'] ?? null);
if ($pharmacyOrderNo) {
$pharmacyOrder = Order::withoutGlobalScopes()
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
->where('order_no', $pharmacyOrderNo)
->first();
if ($pharmacyOrder) {
if (in_array($pharmacyOrder->status, Order::TERMINAL_STATUSES, true)) {
Log::info("Pharmacy order already finalized (terminal)", [
'order_no' => $pharmacyOrderNo,
'status' => $pharmacyOrder->status,
]);
return $pharmacyOrder; // 冪等
}
return $this->finalizePharmacyDispense($pharmacyOrder, $data);
}
}
// 後備:若 flow_id 本身就對到領藥單(例如機台有正確帶 RX flow_id也走領藥流程。
// 領藥單(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");
}
return DB::transaction(function () use ($data, $flowId) {
$serialNo = $data['serial_no'];
// 1. Process Order (B600)
$orderData = $data['order'];
@ -466,7 +402,6 @@ class TransactionService
}
// 3. Record Dispense Results (B602) - Optional/Multiple
$dispensedSlots = []; // 收集實際出貨貨道/櫃號,供取貨碼核銷日誌記錄「真正取貨的貨道」
if (!empty($data['dispense'])) {
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
foreach ($dispenseList as $dispenseItem) {
@ -479,10 +414,6 @@ class TransactionService
$dispenseItem['member_barcode'] = $order->member_barcode;
}
if (!empty($dispenseItem['slot_no'])) {
$dispensedSlots[] = (string) $dispenseItem['slot_no'];
}
$this->recordDispense($dispenseItem);
}
}
@ -508,11 +439,9 @@ class TransactionService
break;
case 6: // 取貨碼 (Pickup Code)
// 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。
$pickupCode = PickupCode::where('machine_id', $order->machine_id)
->where('id', $codeId)
->where('status', 'active')
->lockForUpdate()
->first();
if ($pickupCode) {
$newCount = $pickupCode->usage_count + 1;
@ -534,11 +463,7 @@ class TransactionService
'action' => $isUsedUp ? 'used' : 'consume',
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
'raw_data' => [
// 取貨碼綁「商品」時 pickupCode->slot_no 為 null
// 改記「實際出貨的貨道/櫃號」(dispense),取不到才退回取貨碼綁定貨道。
'slot' => !empty($dispensedSlots)
? implode(', ', array_unique($dispensedSlots))
: $pickupCode->slot_no,
'slot' => $pickupCode->slot_no,
'code' => $pickupCode->code,
'count' => $newCount,
'limit' => $pickupCode->usage_limit
@ -548,8 +473,6 @@ class TransactionService
break;
case 5: // 通行碼 (Pass Code)
// 注意:通行碼「用掉一次」的計數改在 B670 驗證(verifyPassCode)當下處理,
// 因為 App 對通行碼會略過交易結案上報,此 case 5 實務上不會被觸發。
// 建立通行碼核銷日誌
PassCodeLog::create([
'company_id' => $order->company_id,
@ -566,11 +489,9 @@ class TransactionService
// 處理來店禮 (Welcome Gift) 自動核銷
$welcomeGiftId = $data['order']['welcome_gift_id'] ?? ($data['welcome_gift_id'] ?? null);
if ($welcomeGiftId) {
// 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。
$welcomeGift = WelcomeGift::where('machine_id', $order->machine_id)
->where('id', $welcomeGiftId)
->where('status', 'active')
->lockForUpdate()
->first();
if ($welcomeGift) {
$newCount = $welcomeGift->usage_count + 1;
@ -602,125 +523,6 @@ class TransactionService
});
}
/**
* 後台手動補單(機台斷線/漏報時,由系統管理員人工補登銷售紀錄)。
*
* 與機台上報共用底層 recordDispense()(扣庫存)與 recordInvoice()+IssueInvoiceJob後台開發票
* 但訂單標記 order_type=manual、created_by=操作者flow_id 'MAN' 前綴與機台上報區隔,
* 一眼可辨識並天然避開 orders.flow_id 唯一鍵衝突(長度 ≤30、純英數亦相容綠界 RelateNumber
* 扣庫存與開發票皆為選配(由呼叫端決定);權限把關(限系統管理員)在 Controller。
*
* @param array $data {
* serial_no, created_by, payment_type, occurred_at?,
* items: [{product_id, slot_no?, product_name?, price, quantity}],
* deduct_stock?: bool, remark?,
* invoice?: null|{business_tax_id?, carrier_id?, love_code?} // 給定才開立
* }
*/
public function createManualOrder(array $data): Order
{
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$items = $data['items'] ?? [];
if (empty($items)) {
throw new \InvalidArgumentException('Manual order requires at least one item');
}
$totalAmount = collect($items)
->sum(fn ($i) => (float) $i['price'] * (int) $i['quantity']);
// 補單常為事後補登machine_time / created_at 採實際發生時間,使銷售報表落在正確日期。
$occurredAt = !empty($data['occurred_at'])
? \Carbon\Carbon::parse($data['occurred_at'])
: now();
$flowId = $this->generateManualFlowId();
return DB::transaction(function () use ($machine, $flowId, $items, $totalAmount, $occurredAt, $data) {
// 1. 建立訂單(終態 completed + 付款成功)
$order = Order::create([
'company_id' => $machine->company_id,
'flow_id' => $flowId,
'order_no' => $this->generateOrderNo(),
'order_type' => Order::TYPE_MANUAL,
'created_by' => $data['created_by'] ?? null,
'machine_id' => $machine->id,
'payment_type' => (int) ($data['payment_type'] ?? 0),
'total_amount' => $totalAmount,
'original_amount' => $totalAmount,
'discount_amount' => 0,
'pay_amount' => $totalAmount,
'payment_status' => Order::PAYMENT_STATUS_SUCCESS,
'payment_at' => $occurredAt,
'machine_time' => $occurredAt,
'status' => Order::STATUS_COMPLETED,
'delivery_status' => Order::DELIVERY_STATUS_SUCCESS,
'remark' => $data['remark'] ?? null,
'metadata' => ['source' => 'manual'],
]);
// created_at 對齊實際發生時間(補單事後補登,報表需落在真實日期而非補登當下)
$order->forceFill(['created_at' => $occurredAt])->save();
$this->createOrderItems($order, $items);
// 2. 扣庫存(選配):逐單位呼叫 recordDispense與機台上報一致成功才扣 1庫存為 0 時不為負)
if (!empty($data['deduct_stock'])) {
foreach ($items as $item) {
$qty = max(1, (int) ($item['quantity'] ?? 1));
for ($n = 0; $n < $qty; $n++) {
$this->recordDispense([
'serial_no' => $machine->serial_no,
'flow_id' => $flowId,
'order_id' => $order->id,
'slot_no' => $item['slot_no'] ?? null,
'product_id' => $item['product_id'] ?? null,
'amount' => $item['price'],
'dispense_status' => 1,
'machine_time' => $occurredAt,
]);
}
}
}
// 3. 開立電子發票(選配):建 pending 發票 → commit 後派 IssueInvoiceJob 去綠界開立。
// 閘門:唯有機台「電子發票開關」開啟才開立(防禦縱深,與 finalizeTransaction 一致;前端已隱藏選項)。
if (!empty($data['invoice']) && $machine->tax_invoice_enabled) {
$invoiceData = $data['invoice'];
$invoiceData['serial_no'] = $machine->serial_no;
$invoiceData['flow_id'] = $flowId;
$invoiceData['order_id'] = $order->id;
$invoiceData['amount'] = $totalAmount;
$invoiceData['status'] = Invoice::STATUS_PENDING;
$invoiceData['machine_time'] = $occurredAt;
$invoice = $this->recordInvoice($invoiceData);
if ($invoice->status === Invoice::STATUS_PENDING) {
DB::afterCommit(function () use ($invoice) {
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
});
}
} elseif (!empty($data['invoice'])) {
Log::warning('手動補單要求開立發票,但機台已關閉電子發票,略過開立', [
'machine_id' => $machine->id,
'flow_id' => $flowId,
'order_id' => $order->id,
]);
}
return $order;
});
}
/** 產生手動補單專用 flow_id'MAN' 前綴全域唯一、純英數、≤30 字)。 */
protected function generateManualFlowId(): string
{
do {
$flowId = 'MAN' . now()->format('YmdHis') . strtoupper(bin2hex(random_bytes(3)));
} while (Order::withoutGlobalScopes()->where('flow_id', $flowId)->exists());
return $flowId;
}
/**
* 領藥單出貨回報(獨立於銷售流程)。
*

View File

@ -1,28 +0,0 @@
<?php
namespace App\Traits;
/**
* 提供「給人看的 flow_id」accessor去掉機台序號前綴顯示乾淨易讀的機台流水號。
*
* 資料庫仍儲存完整的 `serial_no . flow_id`(全域唯一值),供後台去重 / 冪等 / 對帳判斷使用;
* accessor 只用於畫面與匯出顯示,不改動任何 DB 值。
*
* flow_id 格式serial_no . YYYYMMDDHHMMSSXXXX無分隔符
* 套用此 trait Model 需具備 `flow_id` 欄位與 `machine`(含 serial_no關聯。
*/
trait HasDisplayFlowId
{
public function getDisplayFlowIdAttribute(): string
{
$flowId = (string) ($this->flow_id ?? '');
$serial = $this->machine?->serial_no;
// 僅在確實以該機台序號開頭時才剝除前綴;舊的未前綴資料原樣顯示(防禦)。
if ($serial !== null && $serial !== '' && str_starts_with($flowId, $serial)) {
return substr($flowId, strlen($serial));
}
return $flowId;
}
}

View File

@ -183,7 +183,7 @@ return [
],
'data.FunctionSet' => [
'type' => 'object',
'description' => "非支付功能模組旗標(新定義,機台端待實作對應結構;布林):\n• PickupModule ← pickup_module_enabled取貨模組\n• PickupCode ← pickup_code_enabled取貨碼\n• PassCode ← pass_code_enabled通行碼\n• WelcomeGift ← welcome_gift_enabled來店禮\n• MemberSystem ← member_system_enabled會員系統\n• AmbientTemp ← ambient_temp_monitoring_enabled環境溫度監控\n• PharmacyPickup ← pharmacy_pickup_enabled領藥單雲端建單、掃 QR 出貨。僅在 ShoppingMode=pickup_sheet 取物單模式下有效)",
'description' => "非支付功能模組旗標(新定義,機台端待實作對應結構;布林):\n• PickupModule ← pickup_module_enabled取貨模組\n• PickupCode ← pickup_code_enabled取貨碼\n• PassCode ← pass_code_enabled通行碼\n• WelcomeGift ← welcome_gift_enabled來店禮\n• MemberSystem ← member_system_enabled會員系統\n• AmbientTemp ← ambient_temp_monitoring_enabled環境溫度監控",
],
'data.ShoppingMode' => [
'type' => 'string',
@ -258,7 +258,6 @@ return [
'WelcomeGift' => false,
'MemberSystem' => false,
'AmbientTemp' => false,
'PharmacyPickup' => false,
],
'ShoppingMode' => 'basic',
'LangSet' => [

View File

@ -15,7 +15,7 @@ return [
|
*/
'default' => env('CACHE_STORE', env('CACHE_DRIVER', 'database')),
'default' => env('CACHE_DRIVER', 'file'),
/*
|--------------------------------------------------------------------------

View File

@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 機台專屬定價:針對「特定機台 × 特定商品」覆蓋全域 products.price / member_price。
*
* - 無紀錄(或欄位為 NULL= 沿用全域商品價products.price / member_price
* - B012 下發時,命中的商品會把 t063v03(機台價) / t060v30(會員價) 換成此處的值。
* - 粒度為 (machine_id, product_id)App 端價格模型是 product-level同商品占多貨道共用同一價。
*/
public function up(): void
{
Schema::create('machine_product_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->foreignId('product_id')->constrained('products')->onDelete('cascade');
$table->decimal('price', 10, 2)->nullable()->comment('機台專屬售價NULL=用全域 products.price');
$table->decimal('member_price', 10, 2)->nullable()->comment('機台專屬會員價NULL=用全域 member_price並夾為不高於機台售價');
$table->timestamps();
// 一台機台對同一商品只有一筆覆蓋價
$table->unique(['machine_id', 'product_id']);
});
}
public function down(): void
{
Schema::dropIfExists('machine_product_prices');
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 補貨單歸屬以「機台所綁公司」為準。
* 既有單據特別是系統管理員建立、company_id 寫成建立者 null 的)依機台回填,
* 否則篩選列選公司或租戶帳號登入皆看不到這些補貨單。
*/
return new class extends Migration
{
public function up(): void
{
// 以 machines.company_id 校正 replenishment_orders.company_id含 NULL 安全比較)
DB::statement(<<<'SQL'
UPDATE replenishment_orders AS ro
INNER JOIN machines AS m ON m.id = ro.machine_id
SET ro.company_id = m.company_id
WHERE NOT (ro.company_id <=> m.company_id)
SQL);
}
public function down(): void
{
// 資料校正,無法可靠還原
}
};

View File

@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 貨道「效期 + 鎖定」雙向 LWW 同步所需欄位。
*
* - machine_slots.is_locked : 貨道是否被「鎖定停售」(與 is_active「是否啟用/配置」語意不同,兩者並存)。
* - machine_slots.last_app_lock_rev : 後台已採用的 App 鎖定編輯序號per-field LWW避免比較機台/後台兩個時鐘)。
* - machine_slots.last_app_expiry_rev: 後台已採用的 App 效期/批號編輯序號。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('machine_slots', function (Blueprint $table) {
$table->boolean('is_locked')->default(false)->after('is_active');
$table->unsignedBigInteger('last_app_lock_rev')->default(0)->after('is_locked');
$table->unsignedBigInteger('last_app_expiry_rev')->default(0)->after('last_app_lock_rev');
});
}
public function down(): void
{
Schema::table('machine_slots', function (Blueprint $table) {
$table->dropColumn(['is_locked', 'last_app_lock_rev', 'last_app_expiry_rev']);
});
}
};

View File

@ -1,42 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* 為公司層級的「管理員」角色加入明確的權威旗標 is_company_admin
* 取代過去靠中文 name='管理員' 來辨識主帳號角色的脆弱作法。
*/
public function up(): void
{
if (!Schema::hasColumn('roles', 'is_company_admin')) {
Schema::table('roles', function (Blueprint $table) {
$table->boolean('is_company_admin')->default(false)->after('is_system');
});
}
// Backfill既有的公司層級「管理員」角色標記為 is_company_admin = true
DB::table('roles')
->whereNotNull('company_id')
->where('name', '管理員')
->update(['is_company_admin' => true]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('roles', 'is_company_admin')) {
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('is_company_admin');
});
}
}
};

View File

@ -1,113 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* 帳號樹狀層級化:加入 parent_id建立者 level主帳號=0)。
* 並對既有資料做兩步 backfill
* 1. is_admin 正規化:每家「有帳號但無主帳號」的公司,將最早建立的帳號設為主帳號;多主帳號則只保留最早一個。
* 2. 回填 parent_id / level主帳號 level=0、parent_id=null其餘帳號掛在該公司主帳號下、level=1
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('parent_id')->nullable()->after('company_id');
$table->unsignedTinyInteger('level')->default(0)->after('parent_id');
$table->foreign('parent_id')->references('id')->on('users')->nullOnDelete();
$table->index('parent_id');
});
$companyIds = DB::table('companies')->pluck('id');
// 1. is_admin 正規化
foreach ($companyIds as $cid) {
// a. 軟刪除帳號一律不得為主帳號
DB::table('users')
->where('company_id', $cid)
->whereNotNull('deleted_at')
->where('is_admin', true)
->update(['is_admin' => false]);
// b. 在「未軟刪除」帳號中確保恰有一個主帳號
$admins = DB::table('users')
->where('company_id', $cid)
->whereNull('deleted_at')
->where('is_admin', true)
->orderBy('id')
->pluck('id');
if ($admins->isEmpty()) {
// 無主帳號 → 將最早建立的帳號補為主帳號
$firstId = DB::table('users')
->where('company_id', $cid)
->whereNull('deleted_at')
->orderBy('id')
->value('id');
if ($firstId) {
DB::table('users')->where('id', $firstId)->update(['is_admin' => true]);
}
} elseif ($admins->count() > 1) {
// 多主帳號 → 只保留最早一個,其餘降為一般帳號
DB::table('users')
->whereIn('id', $admins->slice(1)->values())
->update(['is_admin' => false]);
}
}
// 2. 回填 parent_id / level
foreach ($companyIds as $cid) {
// 主帳號必須取自「未軟刪除」帳號
$mainId = DB::table('users')
->where('company_id', $cid)
->whereNull('deleted_at')
->where('is_admin', true)
->value('id');
if (!$mainId) {
continue; // 空公司,略過
}
// 主帳號
DB::table('users')->where('id', $mainId)->update(['parent_id' => null, 'level' => 0]);
// 其餘帳號含軟刪除掛在主帳號下level=1
DB::table('users')
->where('company_id', $cid)
->where('id', '!=', $mainId)
->update(['parent_id' => $mainId, 'level' => 1]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 防禦式:外鍵 / 索引 / 欄位可能因 DDL 半途失敗而部分不存在,逐項忽略不存在的情況。
try {
Schema::table('users', fn (Blueprint $t) => $t->dropForeign(['parent_id']));
} catch (\Throwable $e) {
// 外鍵已不存在,略過
}
try {
Schema::table('users', fn (Blueprint $t) => $t->dropIndex('users_parent_id_index'));
} catch (\Throwable $e) {
// 索引已不存在,略過
}
Schema::table('users', function (Blueprint $table) {
$cols = array_values(array_filter(
['parent_id', 'level'],
fn ($c) => Schema::hasColumn('users', $c)
));
if ($cols) {
$table->dropColumn($cols);
}
});
}
};

View File

@ -1,60 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* 角色樹狀層級化:加入 created_by建立者帳號
* Backfill既有公司層級角色 created_by 指向該公司主帳號;全域系統角色維持 null
*/
public function up(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->unsignedBigInteger('created_by')->nullable()->after('company_id');
$table->foreign('created_by')->references('id')->on('users')->nullOnDelete();
$table->index('created_by');
});
// 既有公司角色歸屬該公司主帳號(未軟刪除的 is_admin
$companyIds = DB::table('roles')->whereNotNull('company_id')->distinct()->pluck('company_id');
foreach ($companyIds as $cid) {
$mainId = DB::table('users')
->where('company_id', $cid)
->whereNull('deleted_at')
->where('is_admin', true)
->value('id');
if ($mainId) {
DB::table('roles')
->where('company_id', $cid)
->whereNull('created_by')
->update(['created_by' => $mainId]);
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
try {
Schema::table('roles', fn (Blueprint $t) => $t->dropForeign(['created_by']));
} catch (\Throwable $e) {
// 外鍵已不存在,略過
}
try {
Schema::table('roles', fn (Blueprint $t) => $t->dropIndex('roles_created_by_index'));
} catch (\Throwable $e) {
// 索引已不存在,略過
}
if (Schema::hasColumn('roles', 'created_by')) {
Schema::table('roles', fn (Blueprint $t) => $t->dropColumn('created_by'));
}
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
// 取貨碼改綁「商品」而非「貨道」:發碼時只指定機台+商品,
// 刷碼當下由 App 依本機即時庫存/鎖定/效期挑可出貨道slot_no 對綁商品碼為 null
// 既有綁貨道碼slot_no 有值、product_id 為 null行為不變向後相容。
$table->unsignedBigInteger('product_id')->nullable()->after('machine_id')
->comment('綁定商品(取貨碼改綁商品時使用;貨道由 App 刷碼當下挑)');
$table->index('product_id');
});
}
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->dropIndex(['product_id']);
$table->dropColumn('product_id');
});
}
};

View File

@ -1,38 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 建立 Laravel 標準 cache / cache_locks 表。
* 對應 dev 的修正 55cf6b8CACHE_STORE=database 須有這兩張表,
* 否則 ProcessHeartbeat 等的 Cache::put 會因缺表而失敗。
*/
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('cache')) {
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
}
if (!Schema::hasTable('cache_locks')) {
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
}
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 取貨碼 / 通行碼 批次新增:以 batch_no 標記同一批產生的碼,供後台批次下載清單使用。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->string('batch_no', 32)->nullable()->after('slug')->index();
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->string('batch_no', 32)->nullable()->after('slug')->index();
});
}
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->dropIndex(['batch_no']);
$table->dropColumn('batch_no');
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->dropIndex(['batch_no']);
$table->dropColumn('batch_no');
});
}
};

View File

@ -1,24 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('order_items', function (Blueprint $table) {
// 消費者選擇的貨道/格子號(格子櫃=櫃號如 501VMC=貨道號)。
// 由 App transaction finalize 的 items[].slot_no 帶入;不管出貨成功或失敗都記錄。
$table->string('slot_no', 20)->nullable()->after('product_name');
});
}
public function down(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('slot_no');
});
}
};

View File

@ -1,24 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('pass_codes', function (Blueprint $table) {
// 可用次數上限null=無限次(維持原行為)。勾「只能使用一次」時=1。
$table->unsignedInteger('usage_limit')->nullable()->after('status');
$table->unsignedInteger('usage_count')->default(0)->after('usage_limit');
});
}
public function down(): void
{
Schema::table('pass_codes', function (Blueprint $table) {
$table->dropColumn(['usage_limit', 'usage_count']);
});
}
};

View File

@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 現金支付面額明細orders 增加 cash_detail JSON 欄位。
* 記錄本筆現金交易「收」進的各面額張數(仟/伍佰//50/10/5/1
* 供銷售紀錄在支付金額下方顯示「收:仟:x、伍佰:x… :x」。
* 找零金額沿用既有 change_amount不另存。
* 僅現金(payment_type=9)交易會帶此欄,其餘交易為 null,不影響既有行為。
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
if (!Schema::hasColumn('orders', 'cash_detail')) {
$table->json('cash_detail')->nullable()
->comment('現金收款面額明細 {b1000,b500,b100,c50,c10,c5,c1}')
->after('change_amount');
}
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
if (Schema::hasColumn('orders', 'cash_detail')) {
$table->dropColumn('cash_detail');
}
});
}
};

View File

@ -1,14 +1,4 @@
{
"Search Product...": "Search Product...",
"Generate Quantity": "Generate Quantity",
"Batch mode: codes are auto-generated and the custom code is ignored.": "Batch mode: codes are auto-generated and the custom code is ignored.",
"Batch generated successfully": "Batch generated successfully",
"codes": "codes",
"Download List (CSV)": "Download List (CSV)",
":count pickup codes generated": ":count pickup codes generated",
":count pass codes created": ":count pass codes created",
"(deducted by issued pickup orders)": "(deducted by issued pickup orders)",
"-- Select Machine --": "-- Select Machine --",
"10s": "10s",
"15 Seconds": "15 Seconds",
"1920x1080 (Max 10MB)": "1920x1080 (Max 10MB)",
@ -37,6 +27,7 @@
"APP Version": "APP Version",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "Abandoned",
"Abnormal": "Abnormal",
"Account": "Account",
"Account :name status has been changed to :status.": "Account :name status has been changed to :status.",
@ -120,11 +111,9 @@
"All Categories": "All Categories",
"All Command Types": "All Command Types",
"All Companies": "All Companies",
"All Dispense Statuses": "All Dispense Statuses",
"All Levels": "All Levels",
"All Machines": "All Machines",
"All Normal": "All Normal",
"All Payment Statuses": "All Payment Statuses",
"All Permissions": "All Permissions",
"All Products (ALL)": "All Products (ALL)",
"All Slots": "All Slots",
@ -228,12 +217,9 @@
"Auto-load succeeded but purchase failed": "Auto-load succeeded but purchase failed",
"Automatically calculate replenishment needs based on machine capacity": "Automatically calculate replenishment needs based on machine capacity",
"Availability": "Availability",
"Available": "Available",
"Available Machines": "Available Machines",
"Avatar updated successfully.": "Avatar updated successfully.",
"Avg Cycle": "Avg Cycle",
"Awaiting Pickup": "Awaiting Pickup",
"B2C (member carrier)": "B2C (member carrier)",
"Back to History": "Back to History",
"Back to List": "Back to List",
"Badge Settings": "Badge Settings",
@ -280,7 +266,6 @@
"Cancel Pass Code": "Cancel Pass Code",
"Cancel Pickup Code": "Cancel Pickup Code",
"Cancel Purchase": "Cancel Purchase",
"Cancel this pharmacy pickup order?": "Cancel this pharmacy pickup order?",
"Cancelled": "Cancelled",
"Cannot Delete Role": "Cannot Delete Role",
"Cannot cancel this order": "Cannot cancel this order",
@ -289,7 +274,6 @@
"Cannot delete company with active accounts.": "Cannot delete company with active accounts.",
"Cannot delete model that is currently in use by machines.": "Cannot delete model that is currently in use by machines.",
"Cannot delete role with active users.": "Cannot delete role with active users.",
"Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "Cannot delete the company main account while other accounts exist. Please transfer the main account role first.",
"Cannot delete warehouse with existing stock": "Cannot delete warehouse with existing stock",
"Cannot match original stored-value transaction": "Cannot match original stored-value transaction",
"Card Machine System": "Card Machine System",
@ -337,7 +321,6 @@
"Choose File": "Choose File",
"Choose Logo": "Choose Logo",
"Choose Overall BG": "Choose Overall BG",
"Citizen certificate carrier": "Citizen certificate carrier",
"Clear": "Clear",
"Clear Abnormal Status": "Clear Abnormal Status",
"Clear Filter": "Clear Filter",
@ -375,7 +358,6 @@
"Company Level": "Company Level",
"Company Name": "Company Name",
"Company Settings": "Company Settings",
"Company Tax ID": "Company Tax ID",
"Complete movement history log": "Complete movement history log",
"Completed": "Completed",
"Completed At": "Completed At",
@ -443,13 +425,10 @@
"Courier/Dispatcher": "Courier/Dispatcher",
"Courier/Replenisher": "Courier/Replenisher",
"Create": "Create",
"Create & Generate QR": "Create & Generate QR",
"Create Config": "Create Config",
"Create Machine": "Create Machine",
"Create New Role": "Create New Role",
"Create Order": "Create Order",
"Create Payment Config": "Create Payment Config",
"Create Pharmacy Pickup Order": "Create Pharmacy Pickup Order",
"Create Product": "Create Product",
"Create Replenishment Order": "Create Replenishment Order",
"Create Role": "Create Role",
@ -462,7 +441,6 @@
"Create stock replenishment for specific machines": "Create stock replenishment for specific machines",
"Create stock transfers between warehouses and machine returns": "Create stock transfers between warehouses and machine returns",
"Create stock-in orders": "Create stock-in orders",
"Created": "Created",
"Created At": "Created At",
"Created By": "Created By",
"Creation Time": "Creation Time",
@ -532,7 +510,6 @@
"Date Range": "Date Range",
"Day Before": "Day Before",
"Days": "Days",
"Deduct stock": "Deduct stock",
"Default": "Default",
"Default BG": "Default BG",
"Default Card BG": "Default Card BG",
@ -544,7 +521,6 @@
"Default Temp Limits": "Default Temp Limits",
"Default Temp Lower Limit (°C)": "Default Temp Lower Limit (°C)",
"Default Temp Upper Limit (°C)": "Default Temp Upper Limit (°C)",
"Defaults to now if empty": "Defaults to now if empty",
"Define and manage security roles and permissions.": "Define and manage security roles and permissions.",
"Define custom temperature limits or inherit from model": "Define custom temperature limits or inherit from model",
"Define new third-party payment parameters": "Define new third-party payment parameters",
@ -626,10 +602,7 @@
"Displayed below Logo on the left (defaults to customer name).": "Displayed below Logo on the left (defaults to customer name).",
"Displayed below main title on the left.": "Displayed below main title on the left.",
"Displaying": "Displaying",
"Donation": "Donation",
"Donation invoices cannot be printed": "Donation invoices cannot be printed",
"Donation love code": "Donation love code",
"Donation love code must be 3 to 7 digits": "Donation love code must be 3 to 7 digits",
"Done": "Done",
"Door Closed": "Door Closed",
"Door Opened": "Door Opened",
@ -777,21 +750,14 @@
"Expiry Time": "Expiry Time",
"Expiry date tracking and warnings": "Expiry date tracking and warnings",
"Export Report": "Export Report",
"Export": "Export",
"Export to CSV": "Export to CSV",
"Export Machine Inventory": "Export Machine Inventory",
"Please select at least one machine": "Please select at least one machine",
"No machines": "No machines",
"Export to Excel": "Export to Excel",
"External URL": "External URL",
"Failed": "Failed",
"Failed to create manual order": "Failed to create manual order",
"Failed to fetch machine data.": "Failed to fetch machine data.",
"Failed to get invoice print URL": "Failed to get invoice print URL",
"Failed to load permissions": "Failed to load permissions",
"Failed to load preview": "Failed to load preview",
"Failed to load pricing": "Failed to load pricing",
"Failed to load slots": "Failed to load slots",
"Failed to load tab content": "Failed to load tab content",
"Failed to resolve issues.": "Failed to resolve issues.",
"Failed to save permissions.": "Failed to save permissions.",
@ -903,7 +869,6 @@
"Flow ID / Slot / Time": "Flow ID / Slot / Time",
"Flow ID / Status": "Flow ID / Status",
"Flow ID / Time": "Flow ID / Time",
"For sales not reported due to disconnection. System administrators only.": "For sales not reported due to disconnection. System administrators only.",
"Force End Session": "Force End Session",
"Force end current session": "Force end current session",
"Forced Update": "Forced Update",
@ -925,7 +890,6 @@
"Generate Replenishment Order": "Generate Replenishment Order",
"Generate and manage one-time pickup codes for customers": "Generate and manage one-time pickup codes for customers",
"Gift Definitions": "Gift Definitions",
"Global": "Global",
"Global roles accessible by all administrators.": "Global roles accessible by all administrators.",
"Go Authorize": "Go Authorize",
"Go Back": "Go Back",
@ -964,13 +928,10 @@
"Identification": "Identification",
"Identity & Codes": "Identity & Codes",
"Identity and Codes": "Identity and Codes",
"Ignore busy state (use when stuck)": "Ignore busy state (use when stuck)",
"Image": "Image",
"Image Downloaded": "Image Downloaded",
"Immediate": "Immediate",
"Import Excel": "Import Excel",
"Export CSV": "Export CSV",
"Export Excel": "Export Excel",
"Import Products": "Import Products",
"Import Staff Cards": "Import Staff Cards",
"Import failed": "Import failed",
@ -991,12 +952,8 @@
"Initial contract registration": "Initial contract registration",
"Installation": "Installation",
"Installation Location": "Installation Location",
"Insufficient stock for :name (requested :qty, available :available).": "Insufficient stock for :name (requested :qty, available :available).",
"Insufficient stock for transfer": "Insufficient stock for transfer",
"Insufficient stored-value / e-wallet balance": "Insufficient stored-value / e-wallet balance",
"Invalid citizen digital certificate carrier": "Invalid citizen digital certificate carrier",
"Invalid invoice type": "Invalid invoice type",
"Invalid mobile barcode carrier": "Invalid mobile barcode carrier",
"Invalid personnel selection": "Invalid personnel selection",
"Invalid status transition": "Invalid status transition",
"Inventory": "Inventory",
@ -1015,13 +972,8 @@
"Invoice Number / Time": "Invoice Number / Time",
"Invoice Status": "Invoice Status",
"Invoice Store ID": "Invoice Store ID",
"Invoice Type": "Invoice Type",
"Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.": "Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.",
"Issue e-invoice": "Issue e-invoice",
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "Issue pharmacy pickup orders from the backend; patient scans QR to dispense.",
"Issued": "Issued",
"Issued At": "Issued At",
"Issued via ECPay in the background after submission.": "Issued via ECPay in the background after submission.",
"Item List": "Item List",
"Items": "Items",
"JKO Pay": "JKO Pay",
@ -1057,7 +1009,6 @@
"Latitude": "Latitude",
"Lease": "Lease",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "Leave blank to deploy immediately, or set a future time to schedule a background release.",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "Leave blank to use the global product price. Member price will be capped at the machine selling price.",
"Leave empty for permanent code": "Leave empty for permanent code",
"Leave empty or 0 for permanent code": "Leave empty or 0 for permanent code",
"Leave empty to inherit company settings": "Leave empty to inherit company settings",
@ -1081,18 +1032,15 @@
"Loading Data": "Loading Data",
"Loading failed": "Loading failed",
"Loading machines...": "Loading machines...",
"Loading slots...": "Loading slots...",
"Location": "Location",
"Location found!": "Location found!",
"Location not found": "Location not found",
"Lock": "Lock",
"Lock (Pause Sale)": "Lock (Pause Sale)",
"Lock Now": "Lock Now",
"Lock Page": "Lock Page",
"Lock Page Lock": "Lock Page Lock",
"Lock Page Unlock": "Lock Page Unlock",
"Locked": "Locked",
"Locked (Sale Paused)": "Locked (Sale Paused)",
"Locked Page": "Locked Page",
"Log Details": "Log Details",
"Log Time": "Log Time",
@ -1130,7 +1078,6 @@
"Machine Flavor": "Machine Flavor",
"Machine Flow ID": "Machine Flow ID",
"Machine Flow ID / Time": "Machine Flow ID / Time",
"Machine Force Reboot": "Machine Force Reboot",
"Machine Images": "Machine Images",
"Machine Inbound Confirmation": "Machine Inbound Confirmation",
"Machine Info": "Machine Info",
@ -1146,8 +1093,6 @@
"Machine Model Settings": "Machine Model Settings",
"Machine Name": "Machine Name",
"Machine Permissions": "Machine Permissions",
"Machine Price": "Machine Price",
"Machine Pricing": "Machine Pricing",
"Machine Reboot": "Machine Reboot",
"Machine Registry": "Machine Registry",
"Machine Replenishment": "Machine Replenishment",
@ -1166,7 +1111,6 @@
"Machine Stock Movements": "Machine Stock Movements",
"Machine System Settings": "Machine System Settings",
"Machine Utilization": "Machine Utilization",
"Machine company changed": "Machine company changed",
"Machine created successfully.": "Machine created successfully.",
"Machine deleted successfully.": "Machine deleted successfully.",
"Machine has reconnected to the server and resumed normal heartbeat.": "Machine has reconnected to the server and resumed normal heartbeat.",
@ -1179,8 +1123,6 @@
"Machine normal": "Machine normal",
"Machine not found": "Machine not found",
"Machine permissions updated successfully.": "Machine permissions updated successfully.",
"Machine pricing saved and sync command pushed.": "Machine pricing saved and sync command pushed.",
"Machine pricing updated": "Machine pricing updated",
"Machine settings updated successfully.": "Machine settings updated successfully.",
"Machine temperature exceeds limit": "Machine temperature exceeds limit, purchase suspended",
"Machine to Warehouse": "Machine to Warehouse",
@ -1224,13 +1166,9 @@
"Manage your warehouses, including main and branch warehouses": "Manage your warehouses, including main and branch warehouses",
"Management of operational parameters": "Management of operational parameters",
"Management of operational parameters and models": "Management of operational parameters and models",
"Manual": "Manual",
"Manual Entry": "Manual Entry",
"Manual Order Entry": "Manual Order Entry",
"Manual Sync Ads": "Manual Sync Ads",
"Manual Sync Products": "Manual Sync Products",
"Manual Sync Settings": "Manual Sync Settings",
"Manual order created: :no": "Manual order created: :no",
"Manufacturer": "Manufacturer",
"Marketing and Loyalty": "Marketing and Loyalty",
"Material Code": "Material Code",
@ -1248,7 +1186,6 @@
"Max Capacity:": "Max Capacity:",
"Max Stock": "Max Stock",
"Maximum file size: 100MB": "Maximum file size: 100MB",
"Medicine": "Medicine",
"Member": "Member",
"Member & External": "Member & External",
"Member + 1": "Member + 1",
@ -1302,7 +1239,6 @@
"Missing RelateNumber, cannot query": "Missing RelateNumber, cannot query",
"Mobile Pay": "Mobile Pay",
"Mobile Payment": "Mobile Payment",
"Mobile barcode carrier": "Mobile barcode carrier",
"Model": "Model",
"Model Default": "Model Default",
"Model Name": "Model Name",
@ -1375,7 +1311,6 @@
"No history records": "No history records",
"No images uploaded": "No images uploaded",
"No invoice issued, cannot void": "No invoice issued, cannot void",
"No items added yet": "No items added yet",
"No location set": "No location set",
"No login history yet": "No login history yet",
"No logs found": "No logs found",
@ -1383,29 +1318,24 @@
"No machines assigned": "No machines assigned",
"No machines available": "No machines available",
"No machines available in this company.": "No machines available in this company.",
"No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).": "No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).",
"No machines found": "No machines found",
"No machines in this status": "No machines in this status",
"No machines match your criteria": "No machines match your criteria",
"No maintenance records found": "No maintenance records found",
"No matching logs found": "No matching logs found",
"No matching machines": "No matching machines",
"No matching products": "No matching products",
"No materials available": "No materials available",
"No movement records found": "No movement records found",
"No movements found": "No movements found",
"No operation logs found": "No operation logs found",
"No pass codes found": "No pass codes found",
"No permissions": "No permissions",
"No pharmacy pickup orders yet.": "No pharmacy pickup orders yet.",
"No pickup codes found": "No pickup codes found",
"No product data matching search criteria": "No product data matching search criteria",
"No product record found": "No product record found",
"No products available for this company": "No products available for this company",
"No products found": "No products found",
"No products found in this warehouse": "No products found in this warehouse",
"No products found matching your criteria.": "No products found matching your criteria.",
"No products on this machine yet": "No products on this machine yet",
"No records found": "No records found",
"No related detail found": "No related detail found",
"No remark": "No remark",
@ -1436,7 +1366,6 @@
"Not Used": "Not Used",
"Not Used Description": "Not Used Description",
"Not a co-branded card": "Not a co-branded card",
"Not stocked on this machine": "Not stocked on this machine",
"Note": "Note",
"Note (optional)": "Note (optional)",
"Notes": "Notes",
@ -1468,7 +1397,6 @@
"Offline Machines": "Offline Machines",
"Old": "Old",
"Old Value": "Old Value",
"On Sale": "On Sale",
"Once": "Once",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.",
@ -1487,7 +1415,6 @@
"Only machines under the same company can be cloned for security.": "Only machines under the same company can be cloned for security.",
"Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried",
"Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued",
"Only system administrators can create manual orders": "Only system administrators can create manual orders",
"Only system administrators can void invoices": "Only system administrators can void invoices",
"Only system roles can be assigned to platform administrative accounts.": "Only system roles can be assigned to platform administrative accounts.",
"Operation Logs": "Operation Logs",
@ -1569,7 +1496,6 @@
"Page 69": "Page 69",
"Page 7": "Page 7",
"Page Lock Status": "Page Lock Status",
"Paid": "Paid",
"Parameters": "Parameters",
"Partial Dispense Success": "Partial Dispense Success",
"Pass Code": "Pass Code",
@ -1590,7 +1516,6 @@
"Payment Amount": "Payment Amount",
"Payment Buffer Seconds": "Payment Buffer Seconds",
"Payment Config": "Payment Config",
"No payment config is selected (Not Used). Save this machine without any payment config?": "No payment config is selected (Not Used). Save this machine without any payment config?",
"Payment Configuration": "Payment Configuration",
"Payment Configuration created successfully.": "Payment Configuration created successfully.",
"Payment Configuration deleted successfully.": "Payment Configuration deleted successfully.",
@ -1611,31 +1536,21 @@
"Permissions": "Permissions",
"Permissions updated successfully": "Permissions updated successfully",
"Personnel assigned successfully": "Personnel assigned successfully",
"Pharmacy Pickup (Rx)": "Pharmacy Pickup (Rx)",
"Pharmacy Pickup Orders": "Pharmacy Pickup Orders",
"Pharmacy pickup order cancelled.": "Pharmacy pickup order cancelled.",
"Pharmacy pickup order created: :no": "Pharmacy pickup order created: :no",
"Phone": "Phone",
"Photo Slot": "Photo Slot",
"Pi Pay": "Pi Pay",
"Pi Wallet": "Pi Wallet",
"Picked Up": "Picked Up",
"Picked up": "Picked up",
"Picked up Time": "Picked up Time",
"Pickup Code": "Pickup Code",
"Pending Use": "Pending Use",
"Generate pickup code? (Valid for pickup within 7 days)": "Generate pickup code? (Valid for pickup within 7 days)",
"Pickup code generated.": "Pickup code generated.",
"Pickup code already generated.": "Pickup code already generated.",
"This order is not eligible for a pickup code.": "This order is not eligible for a pickup code.",
"No undelivered items to compensate.": "No undelivered items to compensate.",
"Machine not found.": "Machine not found.",
"Pickup Code (8 Digits)": "Pickup Code (8 Digits)",
"Pickup Codes": "Pickup Codes",
"Pickup Module": "Pickup Module",
"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.",
@ -1672,10 +1587,8 @@
"Please select a machine to view and manage its advertisements.": "Please select a machine to view and manage its advertisements.",
"Please select a machine to view metrics": "Please select a machine to view metrics",
"Please select a material": "Please select a material",
"Please select a product": "Please select a product",
"Please select a slot": "Please select a slot",
"Please select an APK file to upload": "Please select an APK file to upload",
"Please select at least one medicine with quantity.": "Please select at least one medicine with quantity.",
"Please select warehouse and machine": "Please select warehouse and machine",
"Please use our standard template to ensure data compatibility.": "Please use our standard template to ensure data compatibility.",
"PlusPay": "PlusPay",
@ -1694,10 +1607,8 @@
"Preview Mode - Interactive Vending Portal Mockup": "Preview Mode - Interactive Vending Portal Mockup",
"Preview Mode: Login functionality is disabled.": "Preview Mode: Login functionality is disabled.",
"Previous": "Previous",
"Price": "Price",
"Price / Member": "Price / Member",
"Pricing Information": "Pricing Information",
"Pricing Slip No.": "Pricing Slip No.",
"Primary Theme Color": "Primary Theme Color",
"Print": "Print",
"Print & PDF": "Print & PDF",
@ -1805,7 +1716,6 @@
"Recent Commands": "Recent Commands",
"Recent Login": "Recent Login",
"Recently reported errors or warnings in logs": "Recently reported errors or warnings in logs",
"Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.": "Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.",
"Recommended: 320x320 (Auto-cropped)": "Recommended: 320x320 (Auto-cropped)",
"Records": "Records",
"Refresh Now": "Refresh Now",
@ -1885,9 +1795,7 @@
"Running Status": "Running Status",
"SYSTEM": "SYSTEM",
"Safety Stock": "Safety Stock",
"Sale Paused": "Sale Paused",
"Sale Price": "Sale Price",
"Sale Status": "Sale Status",
"Sales": "Sales",
"Sales Activity": "Sales Activity",
"Sales Amount": "Sales Amount",
@ -1899,7 +1807,6 @@
"Sales Today": "Sales Today",
"Sales revenue minus product cost net value": "Sales revenue minus product cost net value",
"Save": "Save",
"Save & Sync": "Save & Sync",
"Save Changes": "Save Changes",
"Save Config": "Save Config",
"Save Material": "Save Material",
@ -1907,8 +1814,6 @@
"Save Settings": "Save Settings",
"Save Version": "Save Version",
"Save error:": "Save error:",
"Save failed": "Save failed",
"Saved": "Saved",
"Saved.": "Saved.",
"Saving...": "Saving...",
"Scale level and access control": "Scale level and access control",
@ -1918,7 +1823,6 @@
"Scan QR Code": "Scan QR Code",
"Scan Store ID": "Scan Store ID",
"Scan Term ID": "Scan Term ID",
"Scan the QR code or enter the pickup code at the machine to collect your medicine.": "Scan the QR code or enter the pickup code at the machine to collect your medicine.",
"Scan this code at the machine or share the link with the customer.": "Scan this code at the machine or share the link with the customer.",
"Scan this code at the machine to authorize testing or maintenance.": "Scan this code at the machine to authorize testing or maintenance.",
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
@ -1996,8 +1900,6 @@
"Select Target Slot": "Select Target Slot",
"Select Update Time (Optional)": "Select Update Time (Optional)",
"Select Warehouse": "Select Warehouse",
"Select a machine": "Select a machine",
"Select a machine to choose products": "Select a machine to choose products",
"Select a machine to copy settings from...": "Select a machine to copy settings from...",
"Select a machine to deep dive": "Select a machine to deep dive",
"Select a material to play on this machine": "Select a material to play on this machine",
@ -2007,8 +1909,6 @@
"Select date to sync data": "Select date to sync data",
"Select flavor...": "Select flavor...",
"Select future time...": "Select update time...",
"Select payment type": "Select payment type",
"Select slot / product": "Select slot / product",
"Select up to :max languages the machine can switch between. The first one is the default.": "Select up to :max languages the machine can switch between. The first one is the default.",
"Select...": "Select...",
"Selected": "Selected",
@ -2076,7 +1976,6 @@
"Source Machine": "Source Machine",
"Source Warehouse": "Source Warehouse",
"Source of temperature limit settings": "Source of temperature limit settings",
"Spec": "Spec",
"Special Permission": "Special Permission",
"Specification": "Specification",
"Specifications": "Specifications",
@ -2145,7 +2044,6 @@
"Sub Account Management": "Sub Account Management",
"Sub Account Roles": "Sub Account Roles",
"Sub Accounts": "Sub Accounts",
"Sub-accounts at this level cannot create further sub-accounts.": "Sub-accounts at this level cannot create further sub-accounts.",
"Sub-actions": "Sub-actions",
"Sub-machine": "Sub-machine",
"Sub-machine Status": "Sub-machine Status",
@ -2154,7 +2052,6 @@
"Submachine Exception": "Submachine Exception",
"Submachine Exception (B013)": "Submachine Exception (B013)",
"Submit Record": "Submit Record",
"Submitting...": "Submitting...",
"Subtotal": "Subtotal",
"Success": "Success",
"Super Admin": "Super Admin",
@ -2208,7 +2105,6 @@
"Target hardware flavor for this APK": "Target hardware flavor for this APK",
"Tax ID": "Tax ID",
"Tax ID (Optional)": "Tax ID (Optional)",
"Tax ID must be 8 digits": "Tax ID must be 8 digits",
"Tax System": "Tax System",
"Taxation System": "Taxation System",
"Temp Alert Range": "Temp Alert Range",
@ -2234,24 +2130,18 @@
"The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.",
"The Super Admin role is immutable.": "The Super Admin role is immutable.",
"The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.",
"The company admin role cannot be deleted.": "The company admin role cannot be deleted.",
"The image archive contains too many files": "The image archive contains too many files",
"The image archive is too large when extracted": "The image archive is too large when extracted",
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.",
"The selected product does not belong to this machine's company.": "The selected product does not belong to this machine's company.",
"This Month": "This Month",
"This Week": "This Week",
"This account does not belong to this company.": "This account does not belong to this company.",
"This invoice has no printable copy": "This invoice has no printable copy",
"This is a system administrator role. Its name is locked to ensure system stability.": "This is a system administrator role. Its name is locked to ensure system stability.",
"This machine currently has no medicine in stock.": "This machine currently has no medicine in stock.",
"This machine has a pending command. Please wait.": "This machine has a pending command. Please wait.",
"This machine has e-invoice disabled; cannot issue": "This machine has e-invoice disabled; cannot issue",
"This machine has no ECPay invoice settings": "This machine has no ECPay invoice settings",
"This pharmacy pickup order can no longer be cancelled.": "This pharmacy pickup order can no longer be cancelled.",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "This product is not stocked on the selected machine; the code may not be redeemable until restocked.",
"This role belongs to another company and cannot be assigned.": "This role belongs to another company and cannot be assigned.",
"This slot has a pending command. Please wait.": "This slot has a pending command. Please wait.",
"This slot has a pending update. Please wait for the previous command to complete.": "This slot has a pending update. Please wait for the previous command to complete.",
@ -2356,7 +2246,6 @@
"Unlock": "Unlock",
"Unlock Now": "Unlock Now",
"Unlock Page": "Unlock Page",
"Unpaid": "Unpaid",
"Unpublish": "Unpublish",
"Update": "Update",
"Update App": "Update App",
@ -2503,12 +2392,9 @@
"Yesterday": "Yesterday",
"You can assign or change the personnel handling this order": "You can assign or change the personnel handling this order",
"You can select at most :max languages.": "You can select at most :max languages.",
"You cannot assign a role with permissions you do not possess.": "You cannot assign a role with permissions you do not possess.",
"You cannot assign permissions you do not possess.": "You cannot assign permissions you do not possess.",
"You cannot delete your own account.": "You cannot delete your own account.",
"You do not have permission to access replenishment orders": "You do not have permission to access replenishment orders",
"You do not have permission to manage this account.": "You do not have permission to manage this account.",
"You do not have permission to manage this role.": "You do not have permission to manage this role.",
"Your account is disabled.": "Your account is disabled.",
"Your recent account activity": "Your recent account activity",
"[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code",
@ -2538,7 +2424,6 @@
"command.lock": "Lock",
"command.reboot": "Reboot",
"command.reboot_card": "Reboot Card Reader",
"command.reboot_force": "Force Reboot",
"command.reload_stock": "Sync Stock",
"command.unlock": "Unlock",
"command.update_ads": "Sync Ads",
@ -2570,7 +2455,6 @@
"e.g. Taiwan Star": "e.g. Taiwan Star",
"e.g. Test Code for Maintenance": "e.g. Test Code for Maintenance",
"e.g. johndoe": "e.g. johndoe",
"e.g. the hospital pricing slip number": "e.g. the hospital pricing slip number",
"e.g., 12345678": "e.g., 12345678",
"e.g., Beverage": "e.g., Beverage",
"e.g., Company Standard Pay": "e.g., Company Standard Pay",
@ -2646,11 +2530,11 @@
"menu.sales": "Sales Management",
"menu.sales.orders": "Purchase Orders",
"menu.sales.pass-codes": "Pass Codes",
"menu.sales.pharmacy-pickup": "Pharmacy Pickup",
"menu.sales.pickup-codes": "Pickup Codes",
"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",
@ -2677,15 +2561,12 @@
"name_dictionary_key": "name_dictionary_key",
"of": "of",
"of items": "items",
"optional": "optional",
"orders": "orders",
"overrides set": "overrides set",
"pending": "Pending",
"permissions": "Permission Settings",
"permissions.accounts": "Account Management",
"permissions.companies": "Customer Management",
"permissions.roles": "Role Permissions",
"physical": "physical",
"price": "price",
"product": "product",
"product_catalog_synced_to_all_machines": "product_catalog_synced_to_all_machines",
@ -2695,7 +2576,6 @@
"product_updated": "product_updated",
"remote": "Remote Management",
"reservation": "Reservation System",
"reserved": "reserved",
"roles": "Roles",
"s": "s",
"sales": "Sales Management",

View File

@ -1,14 +1,4 @@
{
"Search Product...": "商品を検索...",
"Generate Quantity": "生成数量",
"Batch mode: codes are auto-generated and the custom code is ignored.": "バッチモード:コードは自動生成され、カスタムコードは無視されます。",
"Batch generated successfully": "バッチ生成が完了しました",
"codes": "件",
"Download List (CSV)": "リストをダウンロード (CSV)",
":count pickup codes generated": ":count 件の受取コードを生成しました",
":count pass codes created": ":count 件の通行コードを作成しました",
"(deducted by issued pickup orders)": "(発行済み受取注文の予約分を差し引き済み)",
"-- Select Machine --": "-- 機器を選択 --",
"10s": "10秒",
"15 Seconds": "15秒",
"1920x1080 (Max 10MB)": "1920x1080 (最大10MB)",
@ -37,6 +27,7 @@
"APP Version": "アプリバージョン",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "放棄済み",
"Abnormal": "異常",
"Account": "アカウント",
"Account :name status has been changed to :status.": "アカウント :name のステータスが :status に変更されました。",
@ -120,11 +111,9 @@
"All Categories": "すべてのカテゴリ",
"All Command Types": "すべてのコマンドタイプ",
"All Companies": "すべての会社",
"All Dispense Statuses": "すべての出庫ステータス",
"All Levels": "すべてのレベル",
"All Machines": "すべての機器",
"All Normal": "すべて正常",
"All Payment Statuses": "すべての決済ステータス",
"All Permissions": "すべての権限",
"All Products (ALL)": "全商品 (ALL)",
"All Slots": "すべてのスロット",
@ -228,12 +217,9 @@
"Auto-load succeeded but purchase failed": "オートチャージ成功、但し購買取引失敗",
"Automatically calculate replenishment needs based on machine capacity": "機器容量に基づいて補充必要量を自動計算",
"Availability": "可用性 (Availability)",
"Available": "利用可能在庫",
"Available Machines": "割り当て可能な機器",
"Avatar updated successfully.": "アバターが更新されました。",
"Avg Cycle": "平均サイクル",
"Awaiting Pickup": "受取待ち",
"B2C (member carrier)": "一般B2C会員キャリア",
"Back to History": "履歴に戻る",
"Back to List": "一覧に戻る",
"Badge Settings": "バッジ設定",
@ -280,7 +266,6 @@
"Cancel Pass Code": "パスコードキャンセル",
"Cancel Pickup Code": "受取コードキャンセル",
"Cancel Purchase": "購入キャンセル",
"Cancel this pharmacy pickup order?": "この薬品受取注文を取り消しますか?",
"Cancelled": "キャンセル済み",
"Cannot Delete Role": "ロールを削除できません",
"Cannot cancel this order": "この注文はキャンセルできません",
@ -289,7 +274,6 @@
"Cannot delete company with active accounts.": "有効なアカウントを持つ顧客は削除できません。",
"Cannot delete model that is currently in use by machines.": "機器で使用中のモデルは削除できません。",
"Cannot delete role with active users.": "ユーザーが紐付けられているロールは削除できません。",
"Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "他のアカウントが存在する間は、会社のメインアカウントを削除できません。先にメインアカウントの権限を移譲してください。",
"Cannot delete warehouse with existing stock": "在庫がある倉庫は削除できません",
"Cannot match original stored-value transaction": "元の電子マネー取引と照合できません",
"Card Machine System": "カードリーダーシステム",
@ -337,7 +321,6 @@
"Choose File": "ファイルを選択",
"Choose Logo": "ロゴを選択",
"Choose Overall BG": "全体背景画像を選択",
"Citizen certificate carrier": "自然人証明書キャリア",
"Clear": "クリア",
"Clear Abnormal Status": "異常状態のクリア",
"Clear Filter": "フィルターをクリア",
@ -375,7 +358,6 @@
"Company Level": "会社レベル",
"Company Name": "会社名",
"Company Settings": "会社グローバル設定",
"Company Tax ID": "統一番号",
"Complete movement history log": "完全な在庫変動履歴",
"Completed": "完了済み",
"Completed At": "完了日時",
@ -443,13 +425,10 @@
"Courier/Dispatcher": "配送担当",
"Courier/Replenisher": "配送・補充担当",
"Create": "作成",
"Create & Generate QR": "作成してQRを生成",
"Create Config": "設定作成",
"Create Machine": "機器追加",
"Create New Role": "新規ロール作成",
"Create Order": "注文を作成",
"Create Payment Config": "決済設定作成",
"Create Pharmacy Pickup Order": "薬品受取注文を作成",
"Create Product": "商品作成",
"Create Replenishment Order": "補充伝票作成",
"Create Role": "ロール作成",
@ -462,7 +441,6 @@
"Create stock replenishment for specific machines": "特定の機器の在庫補充伝票を作成",
"Create stock transfers between warehouses and machine returns": "倉庫間の移動または機器からの返品を作成",
"Create stock-in orders": "入庫伝票を作成",
"Created": "作成日時",
"Created At": "作成日時",
"Created By": "作成者",
"Creation Time": "作成時間",
@ -532,7 +510,6 @@
"Date Range": "日付範囲",
"Day Before": "一昨日",
"Days": "日",
"Deduct stock": "在庫を減算",
"Default": "デフォルト",
"Default BG": "デフォルト背景",
"Default Card BG": "デフォルトカード背景",
@ -544,7 +521,6 @@
"Default Temp Limits": "デフォルト温度制御範囲",
"Default Temp Lower Limit (°C)": "デフォルト温度下限 (°C)",
"Default Temp Upper Limit (°C)": "デフォルト温度上限 (°C)",
"Defaults to now if empty": "空欄の場合は現在時刻",
"Define and manage security roles and permissions.": "セキュリティロールと権限を定義・管理します。",
"Define custom temperature limits or inherit from model": "カスタム温度制限を設定するか、機器モデルから継承します",
"Define new third-party payment parameters": "新しい外部決済パラメータを定義",
@ -626,10 +602,7 @@
"Displayed below Logo on the left (defaults to customer name).": "左側のロゴの下に表示されるメインタイトル(未設定の場合はデフォルトで顧客名になります)。",
"Displayed below main title on the left.": "左側のメインタイトルの下に表示されるサブタイトル。",
"Displaying": "表示中",
"Donation": "寄付",
"Donation invoices cannot be printed": "寄付の領収書は印刷できません",
"Donation love code": "寄付の愛心コード",
"Donation love code must be 3 to 7 digits": "寄付の愛心コードは3〜7桁の数字である必要があります",
"Done": "完了",
"Door Closed": "扉が閉まりました",
"Door Opened": "扉が開きました",
@ -781,13 +754,10 @@
"Export to Excel": "Excelエクスポート",
"External URL": "External URL",
"Failed": "失敗",
"Failed to create manual order": "手動補登に失敗しました",
"Failed to fetch machine data.": "機器データの取得に失敗しました。",
"Failed to get invoice print URL": "領収書印刷URLの取得に失敗しました",
"Failed to load permissions": "権限の読み込みに失敗しました",
"Failed to load preview": "プレビューの読み込みに失敗しました",
"Failed to load pricing": "価格設定の読み込みに失敗しました",
"Failed to load slots": "スロットの読み込みに失敗しました",
"Failed to load tab content": "タブ内容の読み込みに失敗しました",
"Failed to resolve issues.": "異常の解消に失敗しました。",
"Failed to save permissions.": "権限の保存に失敗しました。",
@ -899,7 +869,6 @@
"Flow ID / Slot / Time": "フローID / スロット / 時間",
"Flow ID / Status": "フローID / ステータス",
"Flow ID / Time": "フローID / 時間",
"For sales not reported due to disconnection. System administrators only.": "切断などで未報告の売上の補登用。システム管理者のみ。",
"Force End Session": "セッションを強制終了",
"Force end current session": "現在のセッションを強制終了",
"Forced Update": "強制アップデート",
@ -921,7 +890,6 @@
"Generate Replenishment Order": "補充伝票生成",
"Generate and manage one-time pickup codes for customers": "顧客用のワンタイム受取コードを生成・管理",
"Gift Definitions": "ギフト設定",
"Global": "グローバル価格",
"Global roles accessible by all administrators.": "全管理者がアクセス可能なグローバルロール。",
"Go Authorize": "先に授権する",
"Go Back": "戻る",
@ -960,7 +928,6 @@
"Identification": "識別情報",
"Identity & Codes": "識別・コード",
"Identity and Codes": "Identity and Codes",
"Ignore busy state (use when stuck)": "ビジー状態を無視(フリーズ時に使用)",
"Image": "画像",
"Image Downloaded": "画像をダウンロードしました",
"Immediate": "即時",
@ -985,12 +952,8 @@
"Initial contract registration": "初期契約登録",
"Installation": "設置",
"Installation Location": "設置場所",
"Insufficient stock for :name (requested :qty, available :available).": ":name の在庫が不足しています(要求 :qty、利用可能 :available。",
"Insufficient stock for transfer": "在庫不足のため移動できません",
"Insufficient stored-value / e-wallet balance": "電子マネー/電子ウォレット残高不足",
"Invalid citizen digital certificate carrier": "自然人証明書キャリアの形式が無効です",
"Invalid invoice type": "インボイス種別が無効です",
"Invalid mobile barcode carrier": "モバイルバーコードキャリアの形式が無効です",
"Invalid personnel selection": "無効な担当者選択",
"Invalid status transition": "無効なステータス遷移",
"Inventory": "在庫概要",
@ -1009,13 +972,8 @@
"Invoice Number / Time": "領収書番号 / 時間",
"Invoice Status": "発行ステータス",
"Invoice Store ID": "請求書ストアID",
"Invoice Type": "インボイス種別",
"Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.": "薬品受取注文を発行し、QRチケットを印刷します。患者が機器でスキャンして払い出します。",
"Issue e-invoice": "電子インボイスを発行",
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "バックエンドで薬品受取注文を発行し、患者がQRをスキャンして払い出します。",
"Issued": "Issued",
"Issued At": "発行日時",
"Issued via ECPay in the background after submission.": "送信後にバックグラウンドでECPayにて発行。",
"Item List": "商品リスト",
"Items": "件",
"JKO Pay": "街口支付 (JKO Pay)",
@ -1051,7 +1009,6 @@
"Latitude": "緯度",
"Lease": "リース",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "空欄のままにすると即時配信されます。未来の日時を指定すると背景で自動予約配信されます。",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "空欄の場合はグローバル商品価格を使用します。会員価格はマシン販売価格を上限に自動調整されます。",
"Leave empty for permanent code": "空欄で無期限コードになります",
"Leave empty or 0 for permanent code": "空欄または0で無期限コードになります",
"Leave empty to inherit company settings": "空白のままにすると会社のグローバル設定を継承します",
@ -1075,18 +1032,15 @@
"Loading Data": "データを読み込み中",
"Loading failed": "読み込み失敗",
"Loading machines...": "機器を読み込み中...",
"Loading slots...": "スロットを読み込み中...",
"Location": "場所",
"Location found!": "座標を取得しました!",
"Location not found": "場所が見つかりません。ランドマーク名(例:花蓮県庁)を使用してみてください",
"Lock": "ロック",
"Lock (Pause Sale)": "ロック(販売停止)",
"Lock Now": "今すぐロック",
"Lock Page": "ページロック",
"Lock Page Lock": "機器アプリロック",
"Lock Page Unlock": "機器アプリロック解除",
"Locked": "ロック中",
"Locked (Sale Paused)": "ロック中(販売停止)",
"Locked Page": "ロックページ",
"Log Details": "Log Details",
"Log Time": "記録時間",
@ -1124,7 +1078,6 @@
"Machine Flavor": "Machine Flavor",
"Machine Flow ID": "機器フローID",
"Machine Flow ID / Time": "機器フローID / 時間",
"Machine Force Reboot": "アプリ強制再起動",
"Machine Images": "機器写真",
"Machine Inbound Confirmation": "入庫確認",
"Machine Info": "機器情報",
@ -1140,8 +1093,6 @@
"Machine Model Settings": "機器モデル設定",
"Machine Name": "機器名",
"Machine Permissions": "機器権限",
"Machine Price": "マシン価格",
"Machine Pricing": "マシン価格設定",
"Machine Reboot": "機器アプリ再起動",
"Machine Registry": "機器リスト",
"Machine Replenishment": "機器補充伝票",
@ -1160,7 +1111,6 @@
"Machine Stock Movements": "機器在庫変動履歴",
"Machine System Settings": "機器システム設定",
"Machine Utilization": "機器稼働率",
"Machine company changed": "マシンの所属会社が変更されました",
"Machine created successfully.": "機器が正常に作成されました。",
"Machine deleted successfully.": "機器が正常に削除されました。",
"Machine has reconnected to the server and resumed normal heartbeat.": "マシンがサーバーに再接続し、通常のハートビートを再開しました。",
@ -1173,8 +1123,6 @@
"Machine normal": "デバイス正常",
"Machine not found": "機器が見つかりません",
"Machine permissions updated successfully.": "機器権限が正常に更新されました。",
"Machine pricing saved and sync command pushed.": "マシン価格を保存し、同期コマンドを送信しました。",
"Machine pricing updated": "マシン価格を更新しました",
"Machine settings updated successfully.": "機器設定が正常に更新されました。",
"Machine temperature exceeds limit": "設定温度超過のため購入一時停止",
"Machine to Warehouse": "機器から倉庫",
@ -1218,13 +1166,9 @@
"Manage your warehouses, including main and branch warehouses": "本庫と分庫を含む倉庫を管理",
"Management of operational parameters": "運用パラメータの管理",
"Management of operational parameters and models": "運用パラメータとモデルの管理",
"Manual": "手動",
"Manual Entry": "手動入力",
"Manual Order Entry": "手動受注入力",
"Manual Sync Ads": "手動広告同期",
"Manual Sync Products": "手動商品同期",
"Manual Sync Settings": "手動設定同期",
"Manual order created: :no": "手動補登を作成しました::no",
"Manufacturer": "製造メーカー",
"Marketing and Loyalty": "Marketing and Loyalty",
"Material Code": "資材コード",
@ -1242,7 +1186,6 @@
"Max Capacity:": "最大容量:",
"Max Stock": "在庫上限",
"Maximum file size: 100MB": "Maximum file size: 100MB",
"Medicine": "薬品",
"Member": "会員価格",
"Member & External": "会員および外部システム",
"Member + 1": "会員 + 1",
@ -1296,7 +1239,6 @@
"Missing RelateNumber, cannot query": "Missing RelateNumber, cannot query",
"Mobile Pay": "モバイル決済",
"Mobile Payment": "モバイル決済",
"Mobile barcode carrier": "モバイルバーコードキャリア",
"Model": "機器モデル",
"Model Default": "モデルデフォルト",
"Model Name": "モデル名",
@ -1369,7 +1311,6 @@
"No history records": "履歴記録なし",
"No images uploaded": "画像がアップロードされていません",
"No invoice issued, cannot void": "No invoice issued, cannot void",
"No items added yet": "商品が未追加です",
"No location set": "場所が設定されていません",
"No login history yet": "ログイン履歴はまだありません",
"No logs found": "ログが見つかりません",
@ -1377,29 +1318,24 @@
"No machines assigned": "機器が割り当てられていません",
"No machines available": "利用可能な機器がありません",
"No machines available in this company.": "この会社で利用可能な機器がありません。",
"No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).": "薬品受取が有効な機器がありません。「機器システム設定」(受取シートモード → 薬品受取スイッチ)で有効にしてください。",
"No machines found": "機器が見つかりません",
"No machines in this status": "このステータスのデバイスはありません",
"No machines match your criteria": "No machines match your criteria",
"No maintenance records found": "メンテナンス記録が見つかりません",
"No matching logs found": "一致するログが見つかりません",
"No matching machines": "一致する機器がありません",
"No matching products": "一致する商品がありません",
"No materials available": "利用可能な素材がありません",
"No movement records found": "変動記録が見つかりません",
"No movements found": "変動が見つかりません",
"No operation logs found": "No operation logs found",
"No pass codes found": "パスコードが見つかりません",
"No permissions": "権限なし",
"No pharmacy pickup orders yet.": "薬品受取注文はまだありません。",
"No pickup codes found": "受取コードが見つかりません",
"No product data matching search criteria": "検索条件に一致する商品データはありません",
"No product record found": "商品記録が見つかりません",
"No products available for this company": "この会社で販売可能な商品がありません",
"No products found": "商品が見つかりません",
"No products found in this warehouse": "この倉庫に商品は見つかりません",
"No products found matching your criteria.": "条件に一致する商品が見つかりません。",
"No products on this machine yet": "このマシンにはまだ商品がありません",
"No records found": "記録が見つかりません",
"No related detail found": "関連する詳細が見つかりません",
"No remark": "備考なし",
@ -1430,7 +1366,6 @@
"Not Used": "使用しない",
"Not Used Description": "サードパーティ決済を使用しない",
"Not a co-branded card": "提携カードではありません",
"Not stocked on this machine": "この機器に未陳列",
"Note": "備考",
"Note (optional)": "備考 (任意)",
"Notes": "備考",
@ -1462,7 +1397,6 @@
"Offline Machines": "オフライン機器",
"Old": "Old",
"Old Value": "Old Value",
"On Sale": "販売中",
"Once": "一回のみ",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントが削除されると、すべてのリソースとデータは完全に削除されます。アカウントを削除する前に、保持したいデータや情報をダウンロードしてください。",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "アカウントが削除されると、すべてのデータは永久に削除されます。完全に削除することを確認するためにパスワードを入力してください。",
@ -1481,7 +1415,6 @@
"Only machines under the same company can be cloned for security.": "セキュリティのため、同じ会社の機器設定のみコピーできます。",
"Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried",
"Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued",
"Only system administrators can create manual orders": "手動補登はシステム管理者のみ可能です",
"Only system administrators can void invoices": "システム管理者のみが請求書を無効化できます",
"Only system roles can be assigned to platform administrative accounts.": "プラットフォーム管理アカウントにはシステムロールのみを割り当てることができます。",
"Operation Logs": "Operation Logs",
@ -1563,7 +1496,6 @@
"Page 69": "ページ 69 (購入キャンセル)",
"Page 7": "ページ 7 (ロックページ)",
"Page Lock Status": "ページロックステータス",
"Paid": "支払い済み",
"Parameters": "パラメータ設定",
"Partial Dispense Success": "一部出庫成功",
"Pass Code": "パスコード",
@ -1604,15 +1536,10 @@
"Permissions": "権限",
"Permissions updated successfully": "権限が正常に更新されました",
"Personnel assigned successfully": "担当者が正常に割り当てられました",
"Pharmacy Pickup (Rx)": "薬品受取",
"Pharmacy Pickup Orders": "薬品受取注文一覧",
"Pharmacy pickup order cancelled.": "薬品受取注文を取り消しました。",
"Pharmacy pickup order created: :no": "薬品受取注文を作成しました::no",
"Phone": "電話番号",
"Photo Slot": "写真スロット",
"Pi Pay": "Pi 拍錢包 (Pi Pay)",
"Pi Wallet": "Pi Wallet",
"Picked Up": "受取済み",
"Picked up": "受取済み",
"Picked up Time": "受取時間",
"Pickup Code": "受取コード",
@ -1622,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.": "受取コードがキャンセルされました。",
@ -1658,10 +1587,8 @@
"Please select a machine to view and manage its advertisements.": "機器を選択して、その広告を表示および管理してください。",
"Please select a machine to view metrics": "指標を表示する機器を選択してください",
"Please select a material": "素材を選択してください",
"Please select a product": "商品を選択してください",
"Please select a slot": "スロットを選択してください",
"Please select an APK file to upload": "Please select an APK file to upload",
"Please select at least one medicine with quantity.": "数量を入力した薬品を少なくとも1つ選択してください。",
"Please select warehouse and machine": "倉庫と機器を選択してください",
"Please use our standard template to ensure data compatibility.": "データの互換性を確保するために、標準テンプレートを使用してください。",
"PlusPay": "PlusPay",
@ -1680,10 +1607,8 @@
"Preview Mode - Interactive Vending Portal Mockup": "プレビューモード - カスタムブランドログインページのモックアップ",
"Preview Mode: Login functionality is disabled.": "プレビューモード:ログイン機能は無効です。",
"Previous": "前へ",
"Price": "価格",
"Price / Member": "価格 / 会員価格",
"Pricing Information": "価格情報",
"Pricing Slip No.": "請求伝票番号",
"Primary Theme Color": "プライマリーテーマカラー",
"Print": "印刷",
"Print & PDF": "印刷 & PDF",
@ -1791,7 +1716,6 @@
"Recent Commands": "最近のコマンド",
"Recent Login": "最近のログイン",
"Recently reported errors or warnings in logs": "最近のログにエラーまたは警告が報告されています",
"Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.": "機台が実際に出庫済みだが未報告の場合、クラウド在庫を整合するため推奨。",
"Recommended: 320x320 (Auto-cropped)": "推奨サイズ: 320x320 (自動トリミング)",
"Records": "記録",
"Refresh Now": "今すぐ更新",
@ -1871,9 +1795,7 @@
"Running Status": "稼働ステータス",
"SYSTEM": "システムレベル",
"Safety Stock": "安全在庫",
"Sale Paused": "販売停止",
"Sale Price": "販売価格",
"Sale Status": "販売状態",
"Sales": "販売管理",
"Sales Activity": "販売活動",
"Sales Amount": "売上金額",
@ -1885,7 +1807,6 @@
"Sales Today": "本日の販売数",
"Sales revenue minus product cost net value": "販売額から商品コストを差し引いた純利益",
"Save": "保存",
"Save & Sync": "保存して同期",
"Save Changes": "変更を保存",
"Save Config": "設定を保存",
"Save Material": "素材を保存",
@ -1893,8 +1814,6 @@
"Save Settings": "設定を保存",
"Save Version": "Save Version",
"Save error:": "保存エラー:",
"Save failed": "保存に失敗しました",
"Saved": "保存しました",
"Saved.": "保存しました。",
"Saving...": "保存中...",
"Scale level and access control": "スケールレベルとアクセス制御",
@ -1904,7 +1823,6 @@
"Scan QR Code": "QRコードをスキャン",
"Scan Store ID": "スキャンStoreID",
"Scan Term ID": "スキャンTermID",
"Scan the QR code or enter the pickup code at the machine to collect your medicine.": "機器でQRコードをスキャンするか、受取コードを入力して薬品を受け取ってください。",
"Scan this code at the machine or share the link with the customer.": "機器でこのコードをスキャンするか、リンクを顧客と共有してください。",
"Scan this code at the machine to authorize testing or maintenance.": "機器でこのコードをスキャンして、テストまたはメンテナンスを承認します。",
"Scan this code to quickly access the maintenance form for this device.": "このQRコードをスキャンすると、このデバイスのメンテナンスフォームに素早くアクセスできます。",
@ -1982,8 +1900,6 @@
"Select Target Slot": "対象のスロットを選択",
"Select Update Time (Optional)": "更新時間を選択してください(任意)",
"Select Warehouse": "倉庫を選択",
"Select a machine": "機台を選択",
"Select a machine to choose products": "商品を選ぶには先に機台を選択",
"Select a machine to copy settings from...": "設定のコピー元機器を選択...",
"Select a machine to deep dive": "詳細分析する機器を選択",
"Select a material to play on this machine": "この機器で再生する素材を選択",
@ -1993,8 +1909,6 @@
"Select date to sync data": "データを同期する日付を選択",
"Select flavor...": "Select flavor...",
"Select future time...": "配信日時を選択してください...",
"Select payment type": "支払方法を選択",
"Select slot / product": "スロット / 商品を選択",
"Select up to :max languages the machine can switch between. The first one is the default.": "機台が切り替え可能な言語を選択してください(最大 :max 種)。最初の言語がデフォルトになります。",
"Select...": "選択してください...",
"Selected": "選択中",
@ -2062,7 +1976,6 @@
"Source Machine": "移動元自販機",
"Source Warehouse": "移動元倉庫",
"Source of temperature limit settings": "温度制限設定のソース",
"Spec": "規格",
"Special Permission": "特別権限",
"Specification": "仕様",
"Specifications": "仕様",
@ -2131,7 +2044,6 @@
"Sub Account Management": "サブアカウント管理",
"Sub Account Roles": "サブアカウントロール",
"Sub Accounts": "サブアカウント",
"Sub-accounts at this level cannot create further sub-accounts.": "このレベルのサブアカウントは、これ以上下位のサブアカウントを作成できません。",
"Sub-actions": "サブアクション",
"Sub-machine": "サブマシン",
"Sub-machine Status": "Sub-machine Status",
@ -2140,7 +2052,6 @@
"Submachine Exception": "下位機ハードウェア異常",
"Submachine Exception (B013)": "下位機/商品ラック異常 (B013)",
"Submit Record": "記録を提出",
"Submitting...": "送信中...",
"Subtotal": "小計",
"Success": "成功",
"Super Admin": "特権管理者",
@ -2194,7 +2105,6 @@
"Target hardware flavor for this APK": "Target hardware flavor for this APK",
"Tax ID": "統一企業番号",
"Tax ID (Optional)": "統一企業番号 (任意)",
"Tax ID must be 8 digits": "統一番号は8桁の数字である必要があります",
"Tax System": "税務システム",
"Taxation System": "税務システム",
"Temp Alert Range": "アラート温度範囲",
@ -2220,24 +2130,18 @@
"The Super Admin role cannot be deleted.": "特権管理者ロールは削除できません。",
"The Super Admin role is immutable.": "特権管理者ロールは変更できません。",
"The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。",
"The company admin role cannot be deleted.": "会社管理者ロールは削除できません。",
"The image archive contains too many files": "画像アーカイブのファイル数が多すぎます",
"The image archive is too large when extracted": "画像アーカイブの展開後のサイズが大きすぎます",
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。",
"The selected product does not belong to this machine's company.": "選択した商品はこの機器の所属会社に属していません。",
"This Month": "今月",
"This Week": "今週",
"This account does not belong to this company.": "このアカウントは該当企業に所属していません。",
"This invoice has no printable copy": "この請求書は印刷可能な紙面がありません",
"This is a system administrator role. Its name is locked to ensure system stability.": "これはシステム管理者ロールです。システムの安定性を確保するため、名前はロックされています。",
"This machine currently has no medicine in stock.": "この機器には現在、払い出し可能な薬品の在庫がありません。",
"This machine has a pending command. Please wait.": "この機器には実行中のコマンドがあります。しばらくお待ちください。",
"This machine has e-invoice disabled; cannot issue": "この機台は電子インボイスが無効のため発行できません",
"This machine has no ECPay invoice settings": "This machine has no ECPay invoice settings",
"This pharmacy pickup order can no longer be cancelled.": "この薬品受取注文はこれ以上取り消せません。",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "この商品は選択した機器に陳列されていません。補充されるまでコードを引き換えできない場合があります。",
"This role belongs to another company and cannot be assigned.": "このロールは別の会社に属しているため、割り当てることができません。",
"This slot has a pending command. Please wait.": "このスロットには実行中のコマンドがあります。しばらくお待ちください。",
"This slot has a pending update. Please wait for the previous command to complete.": "このスロットには保留中の更新があります。前のコマンドが完了するまでお待ちください。",
@ -2342,7 +2246,6 @@
"Unlock": "ロック解除",
"Unlock Now": "今すぐロック解除",
"Unlock Page": "ページロック解除",
"Unpaid": "未払い",
"Unpublish": "非公開",
"Update": "更新",
"Update App": "アプリ更新",
@ -2489,12 +2392,9 @@
"Yesterday": "昨日",
"You can assign or change the personnel handling this order": "この注文を処理する担当者を割り当て、または変更できます",
"You can select at most :max languages.": "最大 :max 言語まで選択できます。",
"You cannot assign a role with permissions you do not possess.": "自分が保有していない権限を含むロールは割り当てできません。",
"You cannot assign permissions you do not possess.": "自身が持っていない権限を割り当てることはできません。",
"You cannot delete your own account.": "自分自身のアカウントは削除できません。",
"You do not have permission to access replenishment orders": "補充伝票のアクセス権限がありません",
"You do not have permission to manage this account.": "このアカウントを管理する権限がありません。",
"You do not have permission to manage this role.": "このロールを管理する権限がありません。",
"Your account is disabled.": "アカウントが無効化されています。",
"Your recent account activity": "最近のアカウントアクティビティ",
"[PickupCode] Verification failed: :code": "[受取コード] 検証失敗: :code",
@ -2524,7 +2424,6 @@
"command.lock": "ロック",
"command.reboot": "再起動",
"command.reboot_card": "カードリーダー再起動",
"command.reboot_force": "強制再起動",
"command.reload_stock": "在庫同期",
"command.unlock": "ロック解除",
"command.update_ads": "広告同期",
@ -2556,7 +2455,6 @@
"e.g. Taiwan Star": "例:台湾星",
"e.g. Test Code for Maintenance": "例:メンテナンステスト用コード",
"e.g. johndoe": "例yamadataro",
"e.g. the hospital pricing slip number": "例:病院の請求伝票番号",
"e.g., 12345678": "例12345678",
"e.g., Beverage": "例:飲料",
"e.g., Company Standard Pay": "例:会社標準決済",
@ -2632,11 +2530,11 @@
"menu.sales": "販売管理",
"menu.sales.orders": "購入注文",
"menu.sales.pass-codes": "パスコード",
"menu.sales.pharmacy-pickup": "薬品受取",
"menu.sales.pickup-codes": "受取コード",
"menu.sales.promotions": "プロモーション期間",
"menu.sales.records": "販売記録",
"menu.sales.store-gifts": "来店ギフト",
"menu.sales.pharmacy-pickup": "薬品受取",
"menu.special-permission": "特別権限",
"menu.special-permission.clear-stock": "在庫クリア",
"menu.warehouses": "倉庫管理",
@ -2663,15 +2561,12 @@
"name_dictionary_key": "name_dictionary_key",
"of": "/全",
"of items": "件中",
"optional": "任意",
"orders": "件",
"overrides set": "件のマシン価格を設定済み",
"pending": "機器受取待機中",
"permissions": "権限設定",
"permissions.accounts": "アカウント管理",
"permissions.companies": "顧客管理",
"permissions.roles": "ロール権限管理",
"physical": "実在庫",
"price": "price",
"product": "product",
"product_catalog_synced_to_all_machines": "product_catalog_synced_to_all_machines",
@ -2681,7 +2576,6 @@
"product_updated": "product_updated",
"remote": "遠隔管理",
"reservation": "予約システム",
"reserved": "予約",
"roles": "ロール権限",
"s": "秒",
"sales": "販売管理",

View File

@ -1,15 +1,4 @@
{
"Single use only": "只能使用一次",
"Search Product...": "搜尋商品...",
"Generate Quantity": "產生數量",
"Batch mode: codes are auto-generated and the custom code is ignored.": "批次模式:系統自動產生隨機碼,將忽略自訂碼。",
"Batch generated successfully": "批次產生成功",
"codes": "筆",
"Download List (CSV)": "下載清單 (CSV)",
":count pickup codes generated": "已產生 :count 筆取貨碼",
":count pass codes created": "已建立 :count 筆通行碼",
"(deducted by issued pickup orders)": "(已扣除已生成領藥單的預留量)",
"-- Select Machine --": "-- 選擇機台 --",
"10s": "10秒",
"15 Seconds": "15 秒",
"1920x1080 (Max 10MB)": "1920x1080 (最大 10MB)",
@ -38,6 +27,7 @@
"APP Version": "APP版本號",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "已棄單",
"Abnormal": "異常",
"Account": "帳號",
"Account :name status has been changed to :status.": "帳號 :name 的狀態已變更為 :status。",
@ -121,11 +111,9 @@
"All Categories": "所有分類",
"All Command Types": "所有指令類型",
"All Companies": "所有公司",
"All Dispense Statuses": "所有出貨狀態",
"All Levels": "所有層級",
"All Machines": "所有機台",
"All Normal": "運作正常",
"All Payment Statuses": "所有付款狀態",
"All Permissions": "全部權限",
"All Products (ALL)": "全部商品 (ALL)",
"All Slots": "所有貨道",
@ -229,12 +217,9 @@
"Auto-load succeeded but purchase failed": "自動加值成功,但購貨交易失敗",
"Automatically calculate replenishment needs based on machine capacity": "根據機台容量自動計算補貨需求",
"Availability": "可用性 (Availability)",
"Available": "可用庫存",
"Available Machines": "可供分配的機台",
"Avatar updated successfully.": "頭像已成功更新。",
"Avg Cycle": "平均週期",
"Awaiting Pickup": "待領取",
"B2C (member carrier)": "一般 B2C會員載具",
"Back to History": "返回紀錄",
"Back to List": "返回列表",
"Badge Settings": "識別證",
@ -281,7 +266,6 @@
"Cancel Pass Code": "取消通行碼",
"Cancel Pickup Code": "取消取貨碼",
"Cancel Purchase": "取消購買",
"Cancel this pharmacy pickup order?": "確定要作廢這張領藥單嗎?",
"Cancelled": "已取消",
"Cannot Delete Role": "無法刪除該角色",
"Cannot cancel this order": "無法取消此訂單",
@ -290,7 +274,6 @@
"Cannot delete company with active accounts.": "無法刪除仍有客用帳號的客戶。",
"Cannot delete model that is currently in use by machines.": "無法刪除目前正在被機台使用的型號。",
"Cannot delete role with active users.": "無法刪除已有綁定帳號的角色。",
"Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "公司主帳號為唯一主帳號,仍有其他帳號時不可刪除,請先轉移主帳號身分。",
"Cannot delete warehouse with existing stock": "無法刪除仍有庫存的倉庫",
"Cannot match original stored-value transaction": "無法比對原始電票交易",
"Card Machine System": "刷卡機系統",
@ -338,7 +321,6 @@
"Choose File": "選擇檔案",
"Choose Logo": "選擇公司 Logo",
"Choose Overall BG": "選擇大背景圖",
"Citizen certificate carrier": "自然人憑證載具",
"Clear": "清除",
"Clear Abnormal Status": "清除異常狀態",
"Clear Filter": "清除篩選",
@ -376,7 +358,6 @@
"Company Level": "公司層級",
"Company Name": "公司名稱",
"Company Settings": "公司全域設定",
"Company Tax ID": "統一編號",
"Complete movement history log": "完整的庫存異動歷程",
"Completed": "已完成",
"Completed At": "完成時間",
@ -444,13 +425,10 @@
"Courier/Dispatcher": "物流經辦/派送員",
"Courier/Replenisher": "物流經辦/補貨員",
"Create": "新增",
"Create & Generate QR": "建立並產生 QR",
"Create Config": "建立配置",
"Create Machine": "新增機台",
"Create New Role": "建立新角色",
"Create Order": "建立訂單",
"Create Payment Config": "建立金流配置",
"Create Pharmacy Pickup Order": "建立領藥單",
"Create Product": "建立商品",
"Create Replenishment Order": "建立補貨單",
"Create Role": "建立角色",
@ -463,7 +441,6 @@
"Create stock replenishment for specific machines": "為指定機台建立庫存補貨單",
"Create stock transfers between warehouses and machine returns": "建立倉庫間調撥或機台退庫單",
"Create stock-in orders": "建立入庫單",
"Created": "建立時間",
"Created At": "建立時間",
"Created By": "建立者",
"Creation Time": "建立時間",
@ -533,7 +510,6 @@
"Date Range": "日期區間",
"Day Before": "前日",
"Days": "天",
"Deduct stock": "扣除庫存",
"Default": "預設",
"Default BG": "預設大背景",
"Default Card BG": "預設卡片背景",
@ -545,7 +521,6 @@
"Default Temp Limits": "預設溫控範圍",
"Default Temp Lower Limit (°C)": "預設溫度下限 (°C)",
"Default Temp Upper Limit (°C)": "預設溫度上限 (°C)",
"Defaults to now if empty": "留空則為現在時間",
"Define and manage security roles and permissions.": "定義並管理系統安全角色與權限。",
"Define custom temperature limits or inherit from model": "設定自訂溫度限制,或繼承自機台型號",
"Define new third-party payment parameters": "定義新的第三方支付參數",
@ -627,10 +602,7 @@
"Displayed below Logo on the left (defaults to customer name).": "顯示於左側 Logo 下方的主標題(未設定則預設帶入客戶名稱)。",
"Displayed below main title on the left.": "顯示於左側主標題下方的副標題/英文名稱。",
"Displaying": "目前顯示",
"Donation": "捐贈",
"Donation invoices cannot be printed": "捐贈發票無法列印",
"Donation love code": "捐贈愛心碼",
"Donation love code must be 3 to 7 digits": "捐贈愛心碼須為 3 至 7 碼數字",
"Done": "已完成",
"Door Closed": "機門已關閉",
"Door Opened": "機門已開啟",
@ -778,21 +750,14 @@
"Expiry Time": "到期時間",
"Expiry date tracking and warnings": "有效期限追蹤與預警",
"Export Report": "匯出報表",
"Export": "匯出",
"Export to CSV": "匯出成 CSV",
"Export Machine Inventory": "匯出機台庫存",
"Please select at least one machine": "請至少選擇一台機台",
"No machines": "沒有機台",
"Export to Excel": "匯出成 Excel",
"External URL": "外連 URL",
"Failed": "失敗",
"Failed to create manual order": "手動補單失敗",
"Failed to fetch machine data.": "無法取得機台資料。",
"Failed to get invoice print URL": "取得發票列印網址失敗",
"Failed to load permissions": "載入權限失敗",
"Failed to load preview": "載入預覽失敗",
"Failed to load pricing": "載入定價失敗",
"Failed to load slots": "載入貨道失敗",
"Failed to load tab content": "載入分頁內容失敗",
"Failed to resolve issues.": "排除異常失敗。",
"Failed to save permissions.": "無法儲存權限設定。",
@ -904,7 +869,6 @@
"Flow ID / Slot / Time": "流水號 / 貨道 / 時間",
"Flow ID / Status": "流水號 / 狀態",
"Flow ID / Time": "流水號 / 時間",
"For sales not reported due to disconnection. System administrators only.": "用於因斷線未上報的銷售。僅限系統管理員。",
"Force End Session": "強制結束當前會話",
"Force end current session": "強制結束目前的連線",
"Forced Update": "強制更新",
@ -926,7 +890,6 @@
"Generate Replenishment Order": "產生補貨單",
"Generate and manage one-time pickup codes for customers": "產生並管理客戶的一次性取貨碼",
"Gift Definitions": "禮品設定",
"Global": "全域價",
"Global roles accessible by all administrators.": "適用於所有管理者的全域角色。",
"Go Authorize": "前往授權",
"Go Back": "返回",
@ -965,13 +928,10 @@
"Identification": "識別資訊",
"Identity & Codes": "識別與代碼",
"Identity and Codes": "識別與代碼",
"Ignore busy state (use when stuck)": "無視忙碌狀態(卡死時使用)",
"Image": "圖片",
"Image Downloaded": "圖片已下載",
"Immediate": "立即",
"Import Excel": "Excel 匯入",
"Export CSV": "匯出 CSV",
"Export Excel": "匯出 Excel",
"Import Products": "匯入商品",
"Import Staff Cards": "匯入員工識別卡",
"Import failed": "匯入失敗",
@ -992,12 +952,8 @@
"Initial contract registration": "初始合約註冊",
"Installation": "裝機",
"Installation Location": "安裝位置",
"Insufficient stock for :name (requested :qty, available :available).": ":name 庫存不足(需求 :qty可用 :available。",
"Insufficient stock for transfer": "庫存不足,無法調撥",
"Insufficient stored-value / e-wallet balance": "電票/電子錢包餘額不足",
"Invalid citizen digital certificate carrier": "自然人憑證載具格式錯誤",
"Invalid invoice type": "發票類型無效",
"Invalid mobile barcode carrier": "手機條碼載具格式錯誤",
"Invalid personnel selection": "無效的人員選擇",
"Invalid status transition": "無效的狀態轉換",
"Inventory": "庫存概覽",
@ -1016,13 +972,8 @@
"Invoice Number / Time": "發票號碼 / 時間",
"Invoice Status": "發票開立狀態",
"Invoice Store ID": "發票商店代號",
"Invoice Type": "發票類型",
"Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.": "開立領藥單、列印 QR 領藥單,病人到機台掃碼即可出貨。",
"Issue e-invoice": "開立電子發票",
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "由後台開立領藥單,病人到機台掃 QR 出貨。",
"Issued": "已開立",
"Issued At": "開立時間",
"Issued via ECPay in the background after submission.": "送出後於背景透過綠界開立。",
"Item List": "商品清單",
"Items": "筆",
"JKO Pay": "街口支付",
@ -1058,7 +1009,6 @@
"Latitude": "緯度",
"Lease": "租賃",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "留空將立即部署更新,或設定未來時間由背景自動排程發布。",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "留空則沿用全域商品價。會員價會自動夾為不高於機台售價。",
"Leave empty for permanent code": "留空表示永久有效",
"Leave empty or 0 for permanent code": "留空或輸入 0 則為永久代碼",
"Leave empty to inherit company settings": "留空則繼承公司全域設定",
@ -1082,18 +1032,15 @@
"Loading Data": "載入資料中",
"Loading failed": "載入失敗",
"Loading machines...": "正在載入機台...",
"Loading slots...": "載入貨道中...",
"Location": "位置",
"Location found!": "已成功獲取座標!",
"Location not found": "找不到位置,您可以嘗試使用地標名稱(例如:花蓮縣政府)",
"Lock": "鎖定",
"Lock (Pause Sale)": "鎖定(暫停販售)",
"Lock Now": "立即鎖定",
"Lock Page": "頁面鎖定",
"Lock Page Lock": "機台 APP 鎖定",
"Lock Page Unlock": "機台 APP 解鎖",
"Locked": "尚未解鎖",
"Locked (Sale Paused)": "已鎖定(暫停販售)",
"Locked Page": "鎖定頁",
"Log Details": "操作紀錄詳情",
"Log Time": "記錄時間",
@ -1131,7 +1078,6 @@
"Machine Flavor": "機台風味",
"Machine Flow ID": "機台流水號",
"Machine Flow ID / Time": "機台流水號 / 時間",
"Machine Force Reboot": "強制重啟 APP",
"Machine Images": "機台照片",
"Machine Inbound Confirmation": "機台入庫確認",
"Machine Info": "機台資訊",
@ -1147,8 +1093,6 @@
"Machine Model Settings": "機台型號設定",
"Machine Name": "機台名稱",
"Machine Permissions": "機台權限",
"Machine Price": "機台價",
"Machine Pricing": "機台定價",
"Machine Reboot": "機台 APP 重啟",
"Machine Registry": "機台清冊",
"Machine Replenishment": "機台補貨單",
@ -1167,7 +1111,6 @@
"Machine Stock Movements": "機台庫存流水帳",
"Machine System Settings": "機台系統設定",
"Machine Utilization": "機台稼動率",
"Machine company changed": "機台所屬公司已變更",
"Machine created successfully.": "機台已成功建立。",
"Machine deleted successfully.": "機台已成功刪除。",
"Machine has reconnected to the server and resumed normal heartbeat.": "機台已重新連線至伺服器並恢復正常心跳。",
@ -1180,8 +1123,6 @@
"Machine normal": "機台正常",
"Machine not found": "找不到機台",
"Machine permissions updated successfully.": "機台權限已成功更新。",
"Machine pricing saved and sync command pushed.": "機台定價已儲存並推送同步指令。",
"Machine pricing updated": "機台定價已更新",
"Machine settings updated successfully.": "機台設定已成功更新。",
"Machine temperature exceeds limit": "機器超過設定溫度,暫停購買",
"Machine to Warehouse": "機台對倉庫",
@ -1225,13 +1166,9 @@
"Manage your warehouses, including main and branch warehouses": "管理您的倉庫,包含總倉與分倉設定",
"Management of operational parameters": "機台運作參數管理",
"Management of operational parameters and models": "管理運作參數與型號",
"Manual": "手動",
"Manual Entry": "手動補單",
"Manual Order Entry": "手動補單",
"Manual Sync Ads": "手動同步機台廣告",
"Manual Sync Products": "手動同步商品",
"Manual Sync Settings": "手動同步系統設定",
"Manual order created: :no": "已建立手動補單::no",
"Manufacturer": "製造商",
"Marketing and Loyalty": "行銷與點數",
"Material Code": "物料代碼",
@ -1249,7 +1186,6 @@
"Max Capacity:": "最大容量:",
"Max Stock": "庫存上限",
"Maximum file size: 100MB": "檔案大小上限100MB",
"Medicine": "藥品",
"Member": "會員價",
"Member & External": "會員與外部系統",
"Member + 1": "會員 + 1",
@ -1303,7 +1239,6 @@
"Missing RelateNumber, cannot query": "缺少 RelateNumber無法查詢",
"Mobile Pay": "手機支付",
"Mobile Payment": "手機支付",
"Mobile barcode carrier": "手機條碼載具",
"Model": "機台型號",
"Model Default": "型號預設",
"Model Name": "型號名稱",
@ -1376,7 +1311,6 @@
"No history records": "尚無歷史紀錄",
"No images uploaded": "尚未上傳照片",
"No invoice issued, cannot void": "尚未開立發票,無法作廢",
"No items added yet": "尚未新增商品",
"No location set": "尚未設定位置",
"No login history yet": "尚無登入紀錄",
"No logs found": "暫無相關日誌",
@ -1384,29 +1318,24 @@
"No machines assigned": "未分配機台",
"No machines available": "目前沒有可供分配的機台",
"No machines available in this company.": "此客戶目前沒有可供分配的機台。",
"No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).": "尚無啟用領藥單的機台。請至「機台系統設定」(取物單模式 → 領藥單開關)啟用。",
"No machines found": "未找到機台",
"No machines in this status": "此狀態下無設備",
"No machines match your criteria": "沒有符合條件的機台",
"No maintenance records found": "找不到維修紀錄",
"No matching logs found": "找不到符合條件的日誌",
"No matching machines": "查無匹配機台",
"No matching products": "找不到符合的商品",
"No materials available": "沒有可用的素材",
"No movement records found": "查無異動紀錄",
"No movements found": "尚無異動紀錄",
"No operation logs found": "找不到操作紀錄",
"No pass codes found": "找不到通行碼",
"No permissions": "無權限項目",
"No pharmacy pickup orders yet.": "目前尚無領藥單。",
"No pickup codes found": "找不到取貨碼",
"No product data matching search criteria": "沒有符合搜尋條件的商品資料",
"No product record found": "無商品紀錄",
"No products available for this company": "此公司尚無可販售商品",
"No products found": "未找到商品",
"No products found in this warehouse": "此倉庫目前無商品庫存",
"No products found matching your criteria.": "找不到符合條件的商品。",
"No products on this machine yet": "此機台尚無商品",
"No records found": "未找到相關紀錄",
"No related detail found": "無關聯詳細資料",
"No remark": "尚無備註",
@ -1437,7 +1366,6 @@
"Not Used": "不使用",
"Not Used Description": "不使用第三方支付介接",
"Not a co-branded card": "非聯名卡",
"Not stocked on this machine": "未在此機台上架",
"Note": "備註",
"Note (optional)": "備註 (選填)",
"Notes": "備註",
@ -1469,7 +1397,6 @@
"Offline Machines": "離線機台",
"Old": "舊值",
"Old Value": "舊資料",
"On Sale": "販售中",
"Once": "單次使用",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "一旦您的帳號被刪除,其所有資源和數據將被永久刪除。在刪除帳號之前,請下載您希望保留的任何數據或資訊。",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "帳號一旦刪除,所有關連數據將被永久移除。請輸入您的密碼以確認您希望永久刪除此帳號。",
@ -1488,7 +1415,6 @@
"Only machines under the same company can be cloned for security.": "基於安全性,僅限複製同公司旗下的機台設定。",
"Only pending/failed invoices can be queried": "僅待開立/失敗的發票可查詢",
"Only pending/failed invoices can be re-issued": "僅待開立/失敗的發票可補開",
"Only system administrators can create manual orders": "僅系統管理員可手動補單",
"Only system administrators can void invoices": "僅系統管理員可作廢發票",
"Only system roles can be assigned to platform administrative accounts.": "僅系統角色可指派給平台管理帳號。",
"Operation Logs": "操作紀錄",
@ -1570,7 +1496,6 @@
"Page 69": "取消購買",
"Page 7": "鎖定頁",
"Page Lock Status": "頁面鎖定狀態",
"Paid": "已付款",
"Parameters": "參數設定",
"Partial Dispense Success": "部分出貨成功",
"Pass Code": "通行碼",
@ -1591,7 +1516,6 @@
"Payment Amount": "支付金額",
"Payment Buffer Seconds": "金流緩衝時間(s)",
"Payment Config": "金流配置",
"No payment config is selected (Not Used). Save this machine without any payment config?": "尚未選擇金流配置(目前為「不使用」)。確定要在沒有金流配置的情況下儲存嗎?",
"Payment Configuration": "客戶金流設定",
"Payment Configuration created successfully.": "金流設定已成功建立。",
"Payment Configuration deleted successfully.": "金流設定已成功刪除。",
@ -1612,31 +1536,21 @@
"Permissions": "權限",
"Permissions updated successfully": "授權更新成功",
"Personnel assigned successfully": "人員指派成功",
"Pharmacy Pickup (Rx)": "領藥單",
"Pharmacy Pickup Orders": "領藥單列表",
"Pharmacy pickup order cancelled.": "領藥單已作廢。",
"Pharmacy pickup order created: :no": "領藥單已建立::no",
"Phone": "手機號碼",
"Photo Slot": "照片欄位",
"Pi Pay": "Pi 拍錢包",
"Pi Wallet": "PI 拍錢包",
"Picked Up": "已領取",
"Picked up": "領取",
"Picked up Time": "領取時間",
"Pickup Code": "取貨碼",
"Pending Use": "待使用",
"Generate pickup code? (Valid for pickup within 7 days)": "是否產生取貨碼?(取貨 7 天內有效)",
"Pickup code generated.": "取貨碼已產生。",
"Pickup code already generated.": "此訂單已產生過取貨碼。",
"This order is not eligible for a pickup code.": "此訂單不符合產生取貨碼的條件。",
"No undelivered items to compensate.": "沒有需要補償的未出貨商品。",
"Machine not found.": "找不到機台。",
"Pickup Code (8 Digits)": "取貨碼 (8 位數)",
"Pickup Codes": "取貨碼",
"Pickup Module": "取貨模組",
"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.": "取貨碼已取消",
@ -1673,10 +1587,8 @@
"Please select a machine to view and manage its advertisements.": "請選擇一個機台以查看並管理其廣告。",
"Please select a machine to view metrics": "請選擇機台以查看數據",
"Please select a material": "請選擇素材",
"Please select a product": "請選擇商品",
"Please select a slot": "請選擇貨道",
"Please select an APK file to upload": "請選擇要上傳的 APK 檔案",
"Please select at least one medicine with quantity.": "請至少勾選一項藥品並填寫數量。",
"Please select warehouse and machine": "請選擇倉庫和機台",
"Please use our standard template to ensure data compatibility.": "請使用標準範例檔以確保資料相容性。",
"PlusPay": "全盈+Pay",
@ -1695,10 +1607,8 @@
"Preview Mode - Interactive Vending Portal Mockup": "預覽模式 - 客製化品牌登入頁面模擬",
"Preview Mode: Login functionality is disabled.": "預覽模式:不提供帳號登入功能。",
"Previous": "上一頁",
"Price": "價格",
"Price / Member": "售價 / 會員價",
"Pricing Information": "價格資訊",
"Pricing Slip No.": "批價單號",
"Primary Theme Color": "主題色系設定",
"Print": "列印",
"Print & PDF": "列印與 PDF",
@ -1806,7 +1716,6 @@
"Recent Commands": "最近指令",
"Recent Login": "最近登入",
"Recently reported errors or warnings in logs": "近期日誌中有錯誤或警告回報",
"Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.": "當機台實際已出貨但未上報時建議勾選,以校正雲端庫存。",
"Recommended: 320x320 (Auto-cropped)": "建議尺寸320x320 (系統將自動裁切)",
"Records": "筆",
"Refresh Now": "立即更新",
@ -1821,7 +1730,7 @@
"Remote Change": "遠端找零",
"Remote Checkout": "遠端結帳",
"Remote Command Center": "遠端指令中心",
"Remote Dispense": "遠端開櫃",
"Remote Dispense": "遠端出貨",
"Remote Lock": "遠端鎖定",
"Remote Management": "遠端管理",
"Remote Permissions": "遠端管理權限",
@ -1886,9 +1795,7 @@
"Running Status": "運行狀態",
"SYSTEM": "系統層級",
"Safety Stock": "安全庫存",
"Sale Paused": "暫停販售",
"Sale Price": "售價",
"Sale Status": "銷售狀態",
"Sales": "銷售管理",
"Sales Activity": "銷售活動",
"Sales Amount": "銷售金額",
@ -1900,7 +1807,6 @@
"Sales Today": "今日銷售",
"Sales revenue minus product cost net value": "銷售額扣除商品成本淨值",
"Save": "儲存",
"Save & Sync": "儲存並同步",
"Save Changes": "儲存變更",
"Save Config": "儲存配置",
"Save Material": "儲存素材",
@ -1908,8 +1814,6 @@
"Save Settings": "儲存設定",
"Save Version": "儲存版本",
"Save error:": "儲存錯誤:",
"Save failed": "儲存失敗",
"Saved": "已儲存",
"Saved.": "已儲存",
"Saving...": "儲存中...",
"Scale level and access control": "層級與存取控制",
@ -1919,7 +1823,6 @@
"Scan QR Code": "掃描 QR Code",
"Scan Store ID": "掃碼StoreID",
"Scan Term ID": "掃碼TermID",
"Scan the QR code or enter the pickup code at the machine to collect your medicine.": "請至機台掃描 QR 碼,或輸入領藥碼領取藥品。",
"Scan this code at the machine or share the link with the customer.": "請在機台掃描此碼,或將連結分享給客戶。",
"Scan this code at the machine to authorize testing or maintenance.": "請在機台上掃描此 QR Code 以進行測試或維護授權。",
"Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。",
@ -1997,8 +1900,6 @@
"Select Target Slot": "選擇目標貨道",
"Select Update Time (Optional)": "選擇更新時間(選填)",
"Select Warehouse": "選擇倉庫",
"Select a machine": "選擇機台",
"Select a machine to choose products": "請先選擇機台以挑選商品",
"Select a machine to copy settings from...": "選擇要複製設定的來源機台...",
"Select a machine to deep dive": "請選擇機台以開始深度分析",
"Select a material to play on this machine": "選擇要在此機台播放的素材",
@ -2008,8 +1909,6 @@
"Select date to sync data": "選擇日期以同步數據",
"Select flavor...": "選擇風味...",
"Select future time...": "選擇更新時間",
"Select payment type": "選擇付款方式",
"Select slot / product": "選擇貨道 / 商品",
"Select up to :max languages the machine can switch between. The first one is the default.": "選擇機台可切換的語系(最多 :max 種),第一個為預設語系。",
"Select...": "請選擇...",
"Selected": "已選擇",
@ -2077,7 +1976,6 @@
"Source Machine": "來源機台",
"Source Warehouse": "來源倉庫",
"Source of temperature limit settings": "溫度限制設定來源",
"Spec": "規格",
"Special Permission": "特殊權限",
"Specification": "規格",
"Specifications": "規格",
@ -2146,7 +2044,6 @@
"Sub Account Management": "子帳號管理",
"Sub Account Roles": "子帳號角色",
"Sub Accounts": "子帳號",
"Sub-accounts at this level cannot create further sub-accounts.": "此層級的子帳號無法再建立下層子帳號。",
"Sub-actions": "子項目",
"Sub-machine": "下位機",
"Sub-machine Status": "下位機狀態",
@ -2155,7 +2052,6 @@
"Submachine Exception": "下位機硬體異常",
"Submachine Exception (B013)": "下位機/貨道異常 (B013)",
"Submit Record": "提交紀錄",
"Submitting...": "送出中...",
"Subtotal": "小計",
"Success": "成功",
"Super Admin": "超級管理員",
@ -2209,7 +2105,6 @@
"Target hardware flavor for this APK": "此 APK 的目標硬體風味",
"Tax ID": "統一編號",
"Tax ID (Optional)": "統一編號 (選填)",
"Tax ID must be 8 digits": "統一編號須為 8 碼數字",
"Tax System": "稅務系統",
"Taxation System": "稅務系統",
"Temp Alert Range": "告警溫度範圍",
@ -2235,24 +2130,18 @@
"The Super Admin role cannot be deleted.": "超級管理員角色不可刪除。",
"The Super Admin role is immutable.": "超級管理員角色不可修改。",
"The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。",
"The company admin role cannot be deleted.": "公司管理員角色不可刪除。",
"The image archive contains too many files": "圖片壓縮檔內的檔案數量過多",
"The image archive is too large when extracted": "圖片壓縮檔解壓後的體積過大",
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。",
"The selected product does not belong to this machine's company.": "所選商品不屬於此機台所屬的公司。",
"This Month": "本月",
"This Week": "本週",
"This account does not belong to this company.": "此帳號不屬於該公司。",
"This invoice has no printable copy": "此發票無可列印紙本",
"This is a system administrator role. Its name is locked to ensure system stability.": "這是系統管理員角色,名稱已鎖定以確保系統穩定性。",
"This machine currently has no medicine in stock.": "此機台目前無可領藥品庫存。",
"This machine has a pending command. Please wait.": "此機台已有指令正在執行,請稍後。",
"This machine has e-invoice disabled; cannot issue": "此機台未啟用電子發票,無法開立",
"This machine has no ECPay invoice settings": "此機台未設定綠界電子發票",
"This pharmacy pickup order can no longer be cancelled.": "此領藥單已無法作廢。",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "此商品未在所選機台上架,補貨前取貨碼可能無法兌換。",
"This role belongs to another company and cannot be assigned.": "此角色屬於其他公司,無法指派。",
"This slot has a pending command. Please wait.": "此貨道已有指令正在執行,請稍後。",
"This slot has a pending update. Please wait for the previous command to complete.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",
@ -2357,7 +2246,6 @@
"Unlock": "解鎖",
"Unlock Now": "立即解鎖",
"Unlock Page": "頁面解鎖",
"Unpaid": "未支付",
"Unpublish": "下架",
"Update": "編輯",
"Update App": "更新 App",
@ -2504,12 +2392,9 @@
"Yesterday": "昨日",
"You can assign or change the personnel handling this order": "您可以指派或變更處理此訂單的人員",
"You can select at most :max languages.": "最多只能選擇 :max 種語系。",
"You cannot assign a role with permissions you do not possess.": "您無法指派含有您本身未持有權限的角色。",
"You cannot assign permissions you do not possess.": "您無法指派您自身不具備的權限。",
"You cannot delete your own account.": "您無法刪除自己的帳號。",
"You do not have permission to access replenishment orders": "您沒有機台補貨單的權限",
"You do not have permission to manage this account.": "您沒有權限管理此帳號。",
"You do not have permission to manage this role.": "您沒有權限管理此角色。",
"Your account is disabled.": "您的帳號已被停用。",
"Your recent account activity": "最近的帳號活動",
"[PickupCode] Verification failed: :code": "[取貨碼] 驗證失敗: :code",
@ -2539,7 +2424,6 @@
"command.lock": "鎖機",
"command.reboot": "重啟",
"command.reboot_card": "讀卡機重啟",
"command.reboot_force": "強制重啟",
"command.reload_stock": "同步庫存",
"command.unlock": "解鎖",
"command.update_ads": "同步廣告",
@ -2571,7 +2455,6 @@
"e.g. Taiwan Star": "例如:台灣之星",
"e.g. Test Code for Maintenance": "例如:維修測試代碼",
"e.g. johndoe": "例如xiaoming",
"e.g. the hospital pricing slip number": "例如:醫院批價單上的單號",
"e.g., 12345678": "例12345678",
"e.g., Beverage": "例如:飲料",
"e.g., Company Standard Pay": "例如:公司標準支付",
@ -2647,11 +2530,36 @@
"menu.sales": "銷售管理",
"menu.sales.orders": "購買單",
"menu.sales.pass-codes": "通行碼",
"menu.sales.pharmacy-pickup": "領藥單",
"menu.sales.pickup-codes": "取貨碼",
"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": "倉儲管理",
@ -2678,15 +2586,12 @@
"name_dictionary_key": "多語系鍵值",
"of": "/共",
"of items": "筆項目",
"optional": "選填",
"orders": "筆",
"overrides set": "項已設定機台價",
"pending": "等待機台領取",
"permissions": "權限設定",
"permissions.accounts": "帳號管理",
"permissions.companies": "客戶管理",
"permissions.roles": "角色權限管理",
"physical": "實體",
"price": "售價",
"product": "商品",
"product_catalog_synced_to_all_machines": "商品目錄已同步至所有機台",
@ -2696,7 +2601,6 @@
"product_updated": "商品資訊已更新",
"remote": "遠端管理",
"reservation": "預約系統",
"reserved": "預留",
"roles": "角色權限",
"s": "秒",
"sales": "銷售管理",

10
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "html",
"name": "star-cloud",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -875,6 +875,7 @@
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Fuzzyma"
@ -900,6 +901,7 @@
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 14.18"
},
@ -1136,6 +1138,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -1801,6 +1804,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -2105,6 +2109,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -2527,6 +2532,7 @@
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -2623,6 +2629,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2710,6 +2717,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View File

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
本機 MySQL 測試設定:跑需要 MySQL-only DDL(如 machine_stock_movements.type enum)的測試。
用法:./vendor/bin/sail test -c phpunit.mysql.xml
使用 Sail 自動建立的 `testing` 資料庫;RefreshDatabase 會在其上跑 migration。
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View File

@ -75,11 +75,6 @@
}));
return;
}
if (!this.$el.payment_config_id.value.trim()) {
if (!confirm('{{ __("No payment config is selected (Not Used). Save this machine without any payment config?") }}')) {
return;
}
}
this.$el.submit();
}
}" @submit.prevent="submitForm" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6">

View File

@ -207,9 +207,6 @@
// 領藥單(取物單模式下的開關)
pharmacy_pickup_enabled: settings.pharmacy_pickup_enabled === true || settings.pharmacy_pickup_enabled === 1 || settings.pharmacy_pickup_enabled === '1',
// 副櫃系統(格子櫃功能授權,基礎版專用)
subcabinet_enabled: settings.subcabinet_enabled === true || settings.subcabinet_enabled === 1 || settings.subcabinet_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',
@ -958,24 +955,6 @@
</label>
</div>
</div>
<!-- 副櫃系統(格子櫃功能授權;機台端據此顯示副櫃分頁/鎖控板設定/貨道管理) -->
<div 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 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">副櫃系統</h4>
</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[subcabinet_enabled]" value="0">
<input type="checkbox" name="settings[subcabinet_enabled]" value="1"
x-model="machineSettings.subcabinet_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">格子櫃功能</span>
</label>
</div>
</div>
@if(auth()->user()->isSystemAdmin())
<!-- 顯示語系(最多 N 種,第一個為預設)。僅系統管理員可設定。 -->
<div class="p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50">

View File

@ -175,7 +175,6 @@
if ($machine->shopping_cart_enabled) $activeFeatures[] = __('Shopping Cart');
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->settings['subcabinet_enabled'] ?? false) $activeFeatures[] = '副櫃系統';
}
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');
@ -322,7 +321,6 @@
if ($machine->shopping_cart_enabled) $activeFeatures[] = __('Shopping Cart');
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->settings['subcabinet_enabled'] ?? false) $activeFeatures[] = '副櫃系統';
}
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');

View File

@ -444,19 +444,11 @@
}
this.isStaffModalOpen = true;
// 回填所屬公司下拉:
// 1. Preline v3 getInstance(target, true) 回傳的是 collection wrapper 而非實例,
// 其上沒有 setValue 會丟錯導致回填失靈,故不可帶第二個參數。
// 2. Preline 的 option value 為字串company_id 為數字時嚴格比對會失配,統一轉字串;空值用 ' ' 對應 placeholder。
// 同步先設原生 <select>.value 再呼叫 setValue對齊 welcome-gifts / pickup-codes 既有可運作寫法)。
// Re-init Preline select or set value after modal opens
this.$nextTick(() => {
const el = document.getElementById('modal-company-id');
if (el) {
const cid = this.staffFormFields.company_id;
const valStr = (cid !== undefined && cid !== null && cid.toString().trim() !== '') ? cid.toString() : ' ';
el.value = valStr;
const inst = window.HSSelect && window.HSSelect.getInstance(el);
if (inst) inst.setValue(valStr);
const select = HSSelect.getInstance('#modal-company-id', true);
if (select) {
select.setValue(this.staffFormFields.company_id || ' ');
}
});
},

View File

@ -49,7 +49,6 @@
inventoryLoading: false,
startDate: '',
endDate: '',
selectedLevel: '',
tab: 'list',
viewMode: 'fleet',
selectedMachine: null,
@ -322,7 +321,6 @@
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab + '&page=' + page;
if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate;
if (this.selectedLevel) url += '&level=' + this.selectedLevel;
const res = await fetch(url);
const data = await res.json();
@ -722,15 +720,6 @@
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
</svg>
</button>
<a href="{{ route('admin.machines.pricing', $machine) }}"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 dark:hover:bg-amber-500/10 border border-transparent hover:border-amber-500/20 transition-all duration-200"
title="{{ __('Machine Pricing') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
</div>
</td>
</tr>
@ -832,39 +821,6 @@
<p class="text-[9px] font-bold text-slate-400 mt-1">{{ $machine->latest_status_log_time->diffForHumans() }}</p>
@endif
</div>
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Hardware Status') }}</p>
@php $hStatus = $machine->hardware_status; @endphp
<div class="flex flex-col gap-1.5">
{{-- 下位機 --}}
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full {{
$hStatus === 'error' ? 'bg-rose-500 animate-pulse' :
($hStatus === 'warning' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500/50')
}}"></span>
<span class="text-xs font-black {{
$hStatus === 'error' ? 'text-rose-500' :
($hStatus === 'warning' ? 'text-amber-500' : 'text-slate-400')
}} uppercase tracking-widest">{{ __('Sub-machine') }}</span>
</div>
{{-- 刷卡機 (信用卡/電子票證/手機支付共用同一台):僅基礎版且啟用刷卡機才顯示 --}}
@if($machine->show_card_terminal)
@php $ctStatus = $machine->card_terminal_status; @endphp
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full {{
$ctStatus === 'error' ? 'bg-rose-500 animate-pulse' :
($ctStatus === 'warning' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500/50')
}}"></span>
<span class="text-xs font-black {{
$ctStatus === 'error' ? 'text-rose-500' :
($ctStatus === 'warning' ? 'text-amber-500' : 'text-slate-400')
}} uppercase tracking-widest">{{ __('Card Terminal') }}</span>
</div>
@endif
</div>
</div>
<div class="col-span-2">
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
@ -898,14 +854,6 @@
</svg>
{{ __('Logs') }}
</button>
<a href="{{ route('admin.machines.pricing', $machine) }}"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-amber-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ __('Pricing') }}
</a>
</div>
</div>
@empty
@ -995,7 +943,7 @@
<!-- Responsive Search within Panel -->
<div class="mt-2 flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6">
<div class="grid grid-cols-1 sm:flex sm:flex-wrap sm:items-center gap-3 sm:gap-4 flex-1 min-w-0">
<div class="grid grid-cols-1 sm:flex sm:items-center gap-3 sm:gap-4 flex-1">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 group">
<label
class="text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest whitespace-nowrap">{{
@ -1044,22 +992,10 @@
class="luxury-input py-2.5 pl-12 pr-4 w-full sm:w-52 text-sm font-bold tracking-tight cursor-pointer">
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 group w-full sm:w-auto">
<label
class="text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest whitespace-nowrap">{{
__('Level') }}</label>
<x-searchable-select id="log-level-select" name="level" class="w-full sm:w-44"
:placeholder="__('All Levels')" :has-search="false"
@change="selectedLevel = $event.target.value.trim(); fetchLogs(1)">
<option value="info" data-title="{{ __('Info') }}">{{ __('Info') }}</option>
<option value="warning" data-title="{{ __('Warning') }}">{{ __('Warning') }}</option>
<option value="error" data-title="{{ __('Error') }}">{{ __('Error') }}</option>
</x-searchable-select>
</div>
</div>
<div class="flex flex-wrap items-center justify-start sm:justify-end gap-4 sm:gap-6 shrink-0">
<div class="flex flex-wrap items-center justify-start sm:justify-end gap-4 sm:gap-6">
<button @click="confirmResolve()"
class="text-[12px] font-bold text-rose-500 dark:text-rose-400 uppercase tracking-widest hover:text-rose-600 transition-colors flex items-center gap-1.5 px-3 py-1.5 rounded-xl hover:bg-rose-500/5 transition-all whitespace-nowrap">
class="text-[12px] font-bold text-rose-500 dark:text-rose-400 uppercase tracking-widest hover:text-rose-600 transition-colors flex items-center gap-1.5 px-3 py-1.5 rounded-xl hover:bg-rose-500/5 transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
@ -1067,10 +1003,8 @@
</svg>
{{ __('Clear Abnormal Status') }}
</button>
<button @click="startDate = ''; endDate = ''; selectedLevel = '';
(() => { const s = document.getElementById('log-level-select'); const i = window.HSSelect?.getInstance(s); if (i) i.setValue(' '); })();
fetchLogs(1)"
class="text-[12px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5 whitespace-nowrap">
<button @click="startDate = ''; endDate = ''; fetchLogs(1)"
class="text-[12px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"

View File

@ -1,226 +0,0 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6 pb-28"
x-data="machinePricing({{ Js::from($items) }}, '{{ route('admin.machines.pricing.update', $machine) }}')">
{{-- Header --}}
<div class="flex items-center gap-4">
<a href="{{ route('admin.machines.index') }}"
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</a>
<div class="min-w-0">
<h1 class="text-2xl sm:text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight flex items-center gap-3">
<span class="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20">
<svg class="w-6 h-6 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
{{ __('Machine Pricing') }}
</h1>
<div class="mt-2 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
<span class="font-mono text-cyan-600 dark:text-cyan-400 truncate">{{ $machine->serial_no }}</span>
<span class="opacity-50"></span>
<span class="truncate">{{ $machine->name }}</span>
</div>
</div>
</div>
<div class="luxury-card rounded-3xl p-6 sm:p-8 space-y-6">
{{-- Hint + Search --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<p class="text-sm text-slate-500 dark:text-slate-400 font-medium leading-relaxed max-w-2xl">
{{ __('Leave blank to use the global product price. Member price will be capped at the machine selling price.') }}
</p>
<div class="relative w-full sm:w-72 flex-shrink-0">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" x-model="search" class="py-2.5 pl-12 pr-6 block w-full luxury-input"
placeholder="{{ __('Search products...') }}">
</div>
</div>
{{-- Empty (no products in company) --}}
<template x-if="items.length === 0">
<div class="text-center py-20 text-slate-400 font-bold uppercase tracking-widest text-sm">
{{ __('No products available for this company') }}
</div>
</template>
{{-- Table --}}
<div x-show="items.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-200/60 dark:border-slate-700/50">
<th class="text-left py-3 px-3">{{ __('Product') }}</th>
<th class="text-center py-3 px-3 w-40">{{ __('Machine Price') }}</th>
<th class="text-center py-3 px-3 w-40">{{ __('Member Price') }}</th>
</tr>
</thead>
<tbody>
<template x-for="item in filteredItems" :key="item.product_id">
<tr class="border-b border-slate-100 dark:border-slate-800/60 hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors">
{{-- Product --}}
<td class="py-3 px-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-11 h-11 rounded-xl bg-slate-100 dark:bg-slate-800 overflow-hidden flex-shrink-0 flex items-center justify-center text-slate-300">
<template x-if="item.image_url">
<img :src="item.image_url" class="w-full h-full object-cover">
</template>
<template x-if="!item.image_url">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
</template>
</div>
<div class="min-w-0">
<div class="font-black text-slate-800 dark:text-white truncate" x-text="item.name"></div>
<div class="text-[11px] font-bold text-slate-400 uppercase tracking-widest">
{{ __('Global') }}: $<span x-text="Math.floor(item.global_price)"></span>
<template x-if="item.global_member_price !== null">
<span> · {{ __('Member') }} $<span x-text="Math.floor(item.global_member_price)"></span></span>
</template>
</div>
</div>
</div>
</td>
{{-- Machine Price --}}
<td class="py-3 px-3">
<div class="flex items-center h-12 rounded-xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all overflow-hidden">
<button type="button" @click="bump(item, 'override_price', -1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" /></svg>
</button>
<div class="flex-1 min-w-[56px]">
<input type="number" min="1" step="1" inputmode="numeric"
x-model="item.override_price"
:placeholder="Math.floor(item.global_price)"
class="w-full bg-transparent border-none text-center font-black text-slate-800 dark:text-white focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
</div>
<button type="button" @click="bump(item, 'override_price', 1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
</button>
</div>
</td>
{{-- Member Price --}}
<td class="py-3 px-3">
<div class="flex items-center h-12 rounded-xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all overflow-hidden">
<button type="button" @click="bump(item, 'override_member_price', -1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" /></svg>
</button>
<div class="flex-1 min-w-[56px]">
<input type="number" min="1" step="1" inputmode="numeric"
x-model="item.override_member_price"
:placeholder="item.global_member_price !== null ? Math.floor(item.global_member_price) : '—'"
class="w-full bg-transparent border-none text-center font-black text-slate-800 dark:text-white focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
</div>
<button type="button" @click="bump(item, 'override_member_price', 1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
</button>
</div>
</td>
</tr>
</template>
<template x-if="items.length > 0 && filteredItems.length === 0">
<tr><td colspan="3" class="text-center py-12 text-slate-400 font-bold uppercase tracking-widest text-sm">{{ __('No matching products') }}</td></tr>
</template>
</tbody>
</table>
</div>
</div>
{{-- Sticky Save Bar --}}
<div class="fixed bottom-0 inset-x-0 sm:left-auto sm:right-8 sm:bottom-8 z-40 flex justify-end px-4 sm:px-0">
<div class="w-full sm:w-auto bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl border border-slate-200 dark:border-white/10 rounded-2xl shadow-2xl px-5 py-4 flex items-center justify-end gap-3">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest mr-auto sm:mr-2" x-text="overrideCount + ' {{ __('overrides set') }}'"></span>
<a href="{{ route('admin.machines.index') }}"
class="px-5 py-2.5 rounded-xl text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 uppercase tracking-widest transition-colors">
{{ __('Cancel') }}
</a>
<button type="button" @click="save()" :disabled="saving || items.length === 0"
class="px-6 py-2.5 rounded-xl text-sm font-black bg-cyan-500 text-white shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 hover:-translate-y-0.5 active:translate-y-0 transition-all uppercase tracking-widest disabled:opacity-40 disabled:pointer-events-none flex items-center gap-2">
<template x-if="saving">
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
</template>
{{ __('Save & Sync') }}
</button>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('machinePricing', (initialItems, saveUrl) => ({
// 空字串代表「沿用全域價」,以 placeholder 顯示全域價作為提示
items: initialItems.map(p => ({
...p,
override_price: p.override_price !== null ? p.override_price : '',
override_member_price: p.override_member_price !== null ? p.override_member_price : '',
})),
saveUrl,
search: '',
saving: false,
get filteredItems() {
const q = this.search.trim().toLowerCase();
if (!q) return this.items;
return this.items.filter(i => (i.name || '').toLowerCase().includes(q) || String(i.barcode || '').toLowerCase().includes(q));
},
// +/- 步進:欄位為空(沿用全域)時,從全域價起跳;最低 1與後端 min:1 一致)
bump(item, field, delta) {
const base = field === 'override_member_price'
? (item.global_member_price !== null ? item.global_member_price : item.global_price)
: item.global_price;
const cur = (item[field] === '' || item[field] === null) ? base : parseInt(item[field]);
item[field] = Math.max(1, (parseInt(cur) || base) + delta);
},
get overrideCount() {
return this.items.filter(i =>
(i.override_price !== '' && i.override_price !== null) ||
(i.override_member_price !== '' && i.override_member_price !== null)
).length;
},
async save() {
if (this.saving || this.items.length === 0) return;
this.saving = true;
const payload = this.items.map(p => ({
product_id: p.product_id,
price: (p.override_price === '' || p.override_price === null) ? null : p.override_price,
member_price: (p.override_member_price === '' || p.override_member_price === null) ? null : p.override_member_price,
}));
try {
const res = await fetch(this.saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify({ items: payload }),
});
const data = await res.json();
if (res.ok && data.success) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '{{ __('Saved') }}', type: 'success' } }));
} else {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '{{ __('Save failed') }}', type: 'error' } }));
}
} catch (e) {
console.error('Pricing save error:', e);
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Save failed') }}', type: 'error' } }));
} finally {
this.saving = false;
}
}
}));
});
</script>
@endsection

View File

@ -36,6 +36,13 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Add Product') }}</span>
</a>
<div class="order-2 w-full flex items-center gap-2 md:contents">
<button @click="isImportModalOpen = true" type="button"
class="btn-luxury-secondary whitespace-nowrap flex items-center gap-2 transition-all duration-300 flex-1 md:flex-none justify-center min-w-[126px] md:order-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Import Excel') }}</span>
</button>
<button @click="syncToAllMachines()"
class="btn-luxury-ghost group whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5 flex-1 md:flex-none min-w-[150px] md:order-2">
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">

View File

@ -62,41 +62,6 @@ $baseRoute = 'admin.data-config.products';
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button @click="isImportModalOpen = true" type="button"
class="px-4 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Import Excel') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="hidden sm:inline">{{ __('Import Excel') }}</span>
</button>
<div class="relative" x-data="{ exportOpen: false }">
<button type="button" @click="exportOpen = !exportOpen" @click.away="exportOpen = false"
class="px-4 py-2.5 rounded-xl bg-emerald-500 text-white hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Export') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span class="hidden sm:inline">{{ __('Export') }}</span>
<svg class="w-3 h-3 transition-transform duration-200" :class="exportOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div x-show="exportOpen" x-transition
class="absolute right-0 top-full mt-2 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl rounded-2xl p-2 z-30 min-w-[160px]"
x-cloak>
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'csv'])) }}"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📄 {{ __('Export to CSV') }}
</a>
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'excel'])) }}"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📊 {{ __('Export to Excel') }}
</a>
</div>
</div>
</div>
<input type="hidden" name="tab" value="products">

View File

@ -34,7 +34,6 @@
'No active cargo lanes found' => __('No active cargo lanes found'),
'Empty' => __('Empty'),
'Machine Reboot' => __('Machine Reboot'),
'Machine Force Reboot' => __('Machine Force Reboot'),
'Card Reader Reboot' => __('Card Reader Reboot'),
'Remote Settlement' => __('Remote Settlement'),
'Lock Page Lock' => __('Lock Page Lock'),
@ -445,7 +444,6 @@
getCommandName(type) {
const names = {
'reboot': this.translations['Machine Reboot'],
'reboot_force': this.translations['Machine Force Reboot'],
'reboot_card': this.translations['Card Reader Reboot'],
'checkout': this.translations['Remote Settlement'],
'lock': this.translations['Lock Page Lock'],
@ -663,27 +661,6 @@
</div>
</button>
<!-- Force Reboot (ignores busy / stuck dialog) -->
<button @click="sendCommand('reboot_force')"
class="p-6 rounded-3xl border border-amber-200 dark:border-amber-500/30 flex items-center gap-5 hover:border-amber-500/60 dark:hover:border-amber-400/70 hover:bg-amber-500/5 dark:hover:bg-amber-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div
class="w-12 h-12 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 dark:text-amber-400 group-hover:scale-110 transition-transform duration-500 border border-amber-500/20 dark:border-amber-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="text-left">
<div
class="text-sm font-black text-slate-800 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
{{ __('Machine Force Reboot') }}</div>
<div class="text-[11px] text-slate-400 dark:text-slate-500 mt-0.5">
{{ __('Ignore busy state (use when stuck)') }}</div>
</div>
</button>
<!-- Card Reader Reboot -->
<button @click="sendCommand('reboot_card')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">

View File

@ -85,7 +85,6 @@
<div class="w-full sm:w-48">
<x-searchable-select name="command_type" :options="[
'reboot' => __('Machine Reboot'),
'reboot_force' => __('Machine Force Reboot'),
'reboot_card' => __('Card Reader Reboot'),
'checkout' => __('Remote Settlement'),
'lock' => __('Lock Page Lock'),

View File

@ -251,42 +251,6 @@
}
},
async toggleLock() {
this.updating = true;
try {
const csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const res = await fetch(`/admin/machines/${this.selectedMachine.id}/slots/lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf
},
body: JSON.stringify({
slot_no: this.selectedSlot.slot_no,
is_locked: !this.selectedSlot.is_locked
})
});
const data = await res.json();
if (data.success) {
this.showEditModal = false;
window.location.href = "{{ route('admin.remote.stock') }}";
} else {
throw new Error(data.message || 'Unknown error');
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: '{{ __("Save error:") }} ' + e.message,
type: 'error'
}
}));
console.error('Lock toggle error:', e);
} finally {
this.updating = false;
}
},
getSlotColorClass(slot) {
const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) {
@ -332,7 +296,6 @@
case 'sent': return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/20';
case 'success': return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
case 'failed': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
case 'timeout': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
case 'superseded': return 'bg-slate-100 text-slate-500 dark:bg-slate-500/10 dark:text-slate-400 border-slate-200 dark:border-slate-500/20 opacity-80';
default: return 'bg-slate-100 text-slate-600 border-slate-200';
}
@ -341,7 +304,6 @@
getCommandName(type) {
const names = {
'reboot': {{ Js::from(__('Machine Reboot')) }},
'reboot_force': {{ Js::from(__('Machine Force Reboot')) }},
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
'checkout': {{ Js::from(__('Remote Settlement')) }},
'lock': {{ Js::from(__('Lock Page Lock')) }},
@ -359,7 +321,6 @@
'sent': {{ Js::from(__('Sent')) }},
'success': {{ Js::from(__('Success')) }},
'failed': {{ Js::from(__('Failed')) }},
'timeout': {{ Js::from(__('Timeout')) }},
'superseded': {{ Js::from(__('Superseded')) }}
};
return statuses[status] || status;
@ -388,14 +349,6 @@
details += `{{ __('Batch') }} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
}
// 鎖定/解鎖暫停販售切換old/new 僅 is_locked 不同時,避免顯示空白徽章
if ((p.old.is_locked ?? false) !== (p.new.is_locked ?? false)) {
if (details !== `{{ __('Slot') }} ${p.slot_no}: `) details += ', ';
details += p.new.is_locked
? `{{ __('Lock (Pause Sale)') }}`
: `{{ __('Unlock') }}`;
}
return details;
}
return '';
@ -710,21 +663,12 @@
class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<div class="flex items-center gap-1.5">
<template x-if="slot.is_locked">
<div
class="flex items-center gap-1 px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 whitespace-nowrap select-none">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</div>
</template>
<template x-if="slot.max_stock > 0 && slot.stock <= (slot.max_stock * 0.2)">
<div
class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
<template x-if="slot.max_stock > 0 && slot.stock <= (slot.max_stock * 0.2)">
<div
class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
<!-- Product Image -->
@ -830,16 +774,8 @@
</template>
</div>
<div>
<div class="flex items-center gap-2">
<span class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 text-[10px] font-black uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Locked (Sale Paused)') }}
</span>
</template>
</div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<template x-if="slot.product?.id">
<div class="mt-1">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">{{ __('ID') }}: <span x-text="slot.product.id"></span></span>
@ -904,12 +840,7 @@
</div>
</div>
<div class="shrink-0 text-right">
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 text-[10px] font-black px-2.5 py-1.5 rounded-lg bg-rose-500/10 text-rose-500 uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</span>
</template>
<span class="text-xs font-black px-2.5 py-1.5 rounded-lg bg-slate-900/10 dark:bg-white/10" x-text="slot.slot_no"></span>
</div>
</div>
@ -1032,24 +963,6 @@
</div>
</div>
<!-- Slot Lock (暫停販售):遠端鎖定/解鎖此貨道,與機台現場雙向同步 -->
<div
class="mt-6 flex items-center justify-between rounded-2xl border border-slate-100 dark:border-slate-800/50 px-5 py-4">
<div>
<div class="text-sm font-black text-slate-500 uppercase tracking-widest">{{
__('Sale Status') }}</div>
<div class="mt-1 text-xs font-bold"
:class="selectedSlot?.is_locked ? 'text-rose-500' : 'text-emerald-500'"
x-text="selectedSlot?.is_locked ? '{{ __('Locked (Sale Paused)') }}' : '{{ __('On Sale') }}'">
</div>
</div>
<button type="button" @click="toggleLock()" :disabled="updating"
class="px-6 py-3 rounded-xl font-bold transition disabled:opacity-50"
:class="selectedSlot?.is_locked ? 'bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20' : 'bg-rose-500/10 text-rose-600 hover:bg-rose-500/20'"
x-text="selectedSlot?.is_locked ? '{{ __('Unlock') }}' : '{{ __('Lock (Pause Sale)') }}'">
</button>
</div>
</div>
<!-- Footer Actions -->

View File

@ -2,8 +2,7 @@
@section('content')
<div class="space-y-2 pb-20" x-data="salesCenter()"
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)"
@manual-order-created.window="switchTab('orders'); fetchTabData('orders')">
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
{{-- Page Header --}}
<x-page-header :title="$title" :subtitle="$description" />
@ -108,11 +107,6 @@
<x-confirm-modal alpineVar="showReissueModal" confirmAction="submitInvoiceAction()" :title="__('Re-issue')"
:message="__('Re-issue this invoice?')" :confirmText="__('Re-issue')" :cancelText="__('Cancel')"
iconType="info" confirmColor="sky" />
{{-- 手動補單彈窗:僅系統管理員可見 / 使用(後端再次把關) --}}
@if(auth()->user()->isSystemAdmin())
@include('admin.sales.partials.manual-order-modal')
@endif
</div>
@endsection
@ -233,24 +227,6 @@ function salesCenter() {
}
},
async generatePickupCode(orderId) {
if (!confirm('{{ __('Generate pickup code? (Valid for pickup within 7 days)') }}')) return;
try {
const res = await fetch(`/admin/sales/orders/${orderId}/pickup-code`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
}
});
const data = await res.json();
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '', type: data.success ? 'success' : 'error' } }));
if (data.success) this.fetchTabData('orders');
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Operation failed') }}', type: 'error' } }));
}
},
openDetail(orderId) {
this.showPanel = true;
document.getElementById('order-detail-content').innerHTML = `<div class="flex items-center justify-center h-full py-20"><x-luxury-spinner show="true" /></div>`;
@ -398,230 +374,5 @@ function salesCenter() {
}
}
}
function manualOrderForm(slotSelectConfig) {
return {
slotSelectConfig: slotSelectConfig,
show: false,
machineId: '',
taxInvoiceEnabled: false,
slots: [],
loadingSlots: false,
selectedSlot: '',
quantity: 1,
price: 0,
paymentType: '',
occurredAt: '',
deductStock: true,
issueInvoice: false,
invoiceType: 'b2c',
invoiceValue: '',
remark: '',
submitting: false,
_fp: null,
open() {
this.resetForm();
this.show = true;
this.$nextTick(() => {
this.initPicker();
// 重置/初始化 Preline 搜尋下拉的顯示值
this.syncSelect('manual-machine', ' ');
this.syncSelect('manual-payment', ' ');
this.syncSelect('manual-invoice-type', 'b2c');
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
this.updateSlotSelect();
});
},
resetForm() {
this.machineId = '';
this.taxInvoiceEnabled = false;
this.slots = [];
this.selectedSlot = '';
this.quantity = 1;
this.price = 0;
this.paymentType = '';
this.occurredAt = '';
this.deductStock = true;
this.issueInvoice = false;
this.invoiceType = 'b2c';
this.invoiceValue = '';
this.remark = '';
},
initPicker() {
if (this._fp) { this._fp.destroy(); this._fp = null; }
if (window.flatpickr && this.$refs.occurredAt) {
this._fp = flatpickr(this.$refs.occurredAt, {
dateFormat: 'Y-m-d H:i', enableTime: true, time_24hr: true, disableMobile: true,
locale: '{{ app()->getLocale() == 'zh_TW' ? 'zh_tw' : 'en' }}',
});
}
},
async onMachineChange() {
this.slots = [];
this.selectedSlot = '';
this.price = 0;
this.taxInvoiceEnabled = false;
this.issueInvoice = false;
this.updateSlotSelect();
if (!this.machineId) return;
this.loadingSlots = true;
try {
const res = await fetch(`{{ route('admin.sales.manual.machine-slots') }}?machine_id=${this.machineId}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
});
const data = await res.json();
if (data.success) {
this.slots = data.slots || [];
this.taxInvoiceEnabled = !!data.tax_invoice_enabled;
}
} catch (e) {
console.error('load slots failed', e);
window.showToast?.('{{ __('Failed to load slots') }}', 'error');
} finally {
this.loadingSlots = false;
this.updateSlotSelect();
}
},
// 依 slots 重建貨道/商品搜尋下拉(沿用全站 Preline searchable-select 樣式)
updateSlotSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('manual-slot-wrapper');
if (!wrapper) return;
wrapper.querySelectorAll('select').forEach(s => {
try { window.HSSelect?.getInstance(s)?.destroy(); } catch (e) {}
});
wrapper.innerHTML = '';
if (!this.machineId) {
const dummy = document.createElement('div');
dummy.className = 'luxury-input opacity-50 cursor-not-allowed flex items-center justify-between';
dummy.innerHTML = `<span class="text-slate-400">{{ __('Select a machine to choose products') }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
wrapper.appendChild(dummy);
return;
}
const selectEl = document.createElement('select');
selectEl.id = 'manual-slot-' + Date.now();
selectEl.className = 'hidden';
const cleanConfig = JSON.parse(JSON.stringify(this.slotSelectConfig), (key, value) => {
return typeof value === 'string' ? value.replace(/\r?\n|\r/g, ' ').trim() : value;
});
selectEl.setAttribute('data-hs-select', JSON.stringify(cleanConfig));
const ph = document.createElement('option');
ph.value = '';
ph.textContent = "{{ __('Select slot / product') }}";
ph.setAttribute('data-title', "{{ __('Select slot / product') }}");
selectEl.appendChild(ph);
this.slots.forEach(slot => {
const opt = document.createElement('option');
opt.value = slot.slot_no;
const text = `${slot.slot_no} · ${slot.product_name} ({{ __('Stock') }}: ${slot.stock})`;
opt.textContent = text;
opt.setAttribute('data-title', text);
if (String(this.selectedSlot) === String(slot.slot_no)) opt.selected = true;
selectEl.appendChild(opt);
});
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', e => this.onSlotPicked(e.target.value));
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit(['select']);
});
},
onSlotPicked(slotNo) {
this.selectedSlot = slotNo;
const slot = this.slots.find(s => String(s.slot_no) === String(slotNo));
this.price = slot ? Number(slot.price) || 0 : 0;
},
// 同步 Preline 下拉顯示值(沿用取貨碼模式)
syncSelect(id, value) {
const el = document.getElementById(id);
if (!el) return;
const v = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = v;
try { window.HSSelect?.getInstance(el)?.setValue(v); } catch (e) {}
},
get total() {
return Math.round((Number(this.price) || 0) * (Number(this.quantity) || 0));
},
invoiceValueLabel() {
return {
taxid: '{{ __('Company Tax ID') }}',
mobile: '{{ __('Mobile barcode carrier') }}',
citizen: '{{ __('Citizen certificate carrier') }}',
donation: '{{ __('Donation love code') }}',
}[this.invoiceType] || '';
},
validate() {
if (!this.machineId) { window.showToast?.('{{ __('Select a machine') }}', 'error'); return false; }
if (!this.selectedSlot) { window.showToast?.('{{ __('Select slot / product') }}', 'error'); return false; }
if (!this.paymentType) { window.showToast?.('{{ __('Select payment type') }}', 'error'); return false; }
if (!(Number(this.quantity) > 0)) { return false; }
return true;
},
async submit() {
if (this.submitting || !this.validate()) return;
const slot = this.slots.find(s => String(s.slot_no) === String(this.selectedSlot));
if (!slot) { window.showToast?.('{{ __('Select slot / product') }}', 'error'); return; }
this.submitting = true;
try {
const payload = {
machine_id: this.machineId,
payment_type: this.paymentType,
occurred_at: this.occurredAt || null,
deduct_stock: this.deductStock,
issue_invoice: this.taxInvoiceEnabled && this.issueInvoice,
invoice_type: this.invoiceType,
invoice_value: this.invoiceValue,
remark: this.remark,
items: [{
product_id: slot.product_id,
slot_no: slot.slot_no,
price: this.price,
quantity: this.quantity,
}],
};
const res = await fetch('{{ route('admin.sales.manual.store') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok && data.success) {
window.showToast?.(data.message || 'OK', 'success');
this.show = false;
window.dispatchEvent(new CustomEvent('manual-order-created'));
} else {
window.showToast?.(data.message || '{{ __('Failed to create manual order') }}', 'error');
}
} catch (e) {
console.error('submit manual order failed', e);
window.showToast?.('{{ __('Failed to create manual order') }}', 'error');
} finally {
this.submitting = false;
}
},
};
}
</script>
@endsection

View File

@ -1,29 +0,0 @@
@if(session('code_batch'))
@php($batch = session('code_batch'))
<div class="luxury-card rounded-3xl p-6 border border-emerald-500/30 bg-emerald-500/5 flex flex-col sm:flex-row sm:items-center gap-4 animate-luxury-in">
<div class="flex items-center gap-3 flex-1">
<div class="w-11 h-11 rounded-2xl bg-emerald-500/15 text-emerald-500 flex items-center justify-center shrink-0">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p class="text-sm font-black text-slate-800 dark:text-white">{{ __('Batch generated successfully') }}</p>
<p class="text-xs font-bold text-slate-500">
{{ __('Batch No') }}: <span class="font-mono text-cyan-600 dark:text-cyan-400">{{ $batch['batch_no'] }}</span>
· {{ $batch['count'] }} {{ __('codes') }}
</p>
</div>
</div>
{{-- download 屬性讓全域連結攔截器layouts/admin跳過「導航載入遮罩」
否則檔案下載不會換頁、遮罩不會關閉,畫面會卡在模糊狀態。 --}}
<a href="{{ $batch['download_url'] }}" download
class="btn-luxury-primary whitespace-nowrap flex items-center justify-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 12l4.5 4.5m0 0l4.5-4.5M12 3v13.5" />
</svg>
{{ __('Download List (CSV)') }}
</a>
</div>
@endif

View File

@ -1,223 +0,0 @@
@php
// 貨道/商品搜尋下拉的 Preline 設定(沿用全站 searchable-select 樣式)
$manualSlotSelectConfig = [
"placeholder" => __('Select slot / product'),
"hasSearch" => true,
"searchPlaceholder" => __('Search...'),
"isHidePlaceholder" => false,
"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses" => "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses" => "hs-select-toggle luxury-select-toggle",
"toggleTemplate" => '<button type="button" aria-expanded="false"><span class="me-2" data-icon></span><span class="text-slate-800 dark:text-slate-200" data-title></span><div class="ms-auto"><svg class="size-4 text-slate-400 transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg></div></button>',
"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[200] animate-luxury-in",
"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate" => '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>',
];
@endphp
{{-- 手動補單彈窗(機台斷線/漏報時人工補登銷售紀錄)— 僅系統管理員 --}}
<div x-data="manualOrderForm(@js($manualSlotSelectConfig))" x-cloak
@open-manual-order.window="open()"
@keydown.window.escape="show = false">
<div class="fixed inset-0 z-[110] overflow-y-auto" x-show="show">
{{-- Backdrop --}}
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
x-show="show"
x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@click="show = false"></div>
{{-- Dialog --}}
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative w-full max-w-2xl transform transition-all"
x-show="show"
x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-6 scale-95" x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0 scale-95">
<div class="luxury-card rounded-3xl bg-white dark:bg-slate-900 shadow-2xl border border-slate-100 dark:border-slate-800 overflow-hidden">
{{-- Header --}}
<div class="flex items-start justify-between gap-4 px-6 sm:px-8 pt-6 pb-4 border-b border-slate-100 dark:border-slate-800">
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-slate-100 tracking-tight flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-cyan-500"></span>
{{ __('Manual Order Entry') }}
</h3>
<p class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 tracking-wide">
{{ __('For sales not reported due to disconnection. System administrators only.') }}
</p>
</div>
<button type="button" @click="show = false"
class="p-2 rounded-xl text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
{{-- Body --}}
<div class="px-6 sm:px-8 py-6 space-y-5 max-h-[68vh] overflow-y-auto">
{{-- 機台 + 發生時間 --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="manual_machine_id" id="manual-machine"
:placeholder="__('Select a machine')"
x-model="machineId" @change="machineId = $event.target.value; onMachineChange()">
@foreach($machines as $m)
<option value="{{ $m->id }}" data-title="{{ $m->name }} ({{ $m->serial_no }})">{{ $m->name }} ({{ $m->serial_no }})</option>
@endforeach
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Transaction Time') }}</label>
<input type="text" x-ref="occurredAt" x-model="occurredAt"
placeholder="{{ __('Defaults to now if empty') }}"
class="luxury-input w-full text-sm py-2.5 px-4 cursor-pointer">
</div>
</div>
{{-- 貨道/商品(搜尋式,依機台動態載入) --}}
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Select slot / product') }} <span class="text-rose-500">*</span></label>
<div class="relative">
<div id="manual-slot-wrapper" class="relative">{{-- Rebuilt by Alpine --}}</div>
<div x-show="loadingSlots" class="absolute right-12 top-1/2 -translate-y-1/2 z-10">
<x-luxury-spinner show="loadingSlots" />
</div>
</div>
</div>
{{-- 付款方式 + 價格 --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Payment Type') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="manual_payment_type" id="manual-payment"
:placeholder="__('Select payment type')"
x-model="paymentType" @change="paymentType = $event.target.value">
@foreach($paymentTypes as $ptVal => $ptLabel)
<option value="{{ $ptVal }}" data-title="{{ $ptLabel }}">{{ $ptLabel }}</option>
@endforeach
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Price') }} <span class="text-rose-500">*</span></label>
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden w-full group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="price = Math.max(0, (Number(price)||0) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-0 flex items-center justify-center">
<span class="text-slate-400 font-black mr-0.5">$</span>
<input type="text" inputmode="decimal" x-model.number="price"
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="price = (Number(price)||0) + 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
</div>
{{-- 數量Luxury Counter +/- --}}
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Qty') }}</label>
<div class="flex flex-col sm:flex-row items-center gap-6">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[200px] w-full group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity > 1 ? quantity-- : 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[60px]">
<input type="text" x-model.number="quantity" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity < 999 ? quantity++ : 999"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in [1, 2, 3, 5]" :key="val">
<button type="button" @click="quantity = val"
class="w-10 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
</div>
{{-- 小計 --}}
<div class="flex items-center justify-end gap-2 pt-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Total') }}</span>
<span class="text-lg font-black text-slate-800 dark:text-white" x-text="`$${total}`"></span>
</div>
{{-- 扣庫存 --}}
<label class="flex items-start gap-3 p-3 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 cursor-pointer">
<input type="checkbox" x-model="deductStock" class="mt-0.5 rounded border-slate-300 text-cyan-500 focus:ring-cyan-500">
<span>
<span class="block text-sm font-bold text-slate-700 dark:text-slate-200">{{ __('Deduct stock') }}</span>
<span class="block text-[11px] text-slate-400 mt-0.5">{{ __('Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.') }}</span>
</span>
</label>
{{-- 開立電子發票(僅機台啟用電子發票時顯示) --}}
<div x-show="taxInvoiceEnabled" x-cloak>
<label class="flex items-start gap-3 p-3 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 cursor-pointer">
<input type="checkbox" x-model="issueInvoice" class="mt-0.5 rounded border-slate-300 text-cyan-500 focus:ring-cyan-500">
<span class="flex-1">
<span class="block text-sm font-bold text-slate-700 dark:text-slate-200">{{ __('Issue e-invoice') }}</span>
<span class="block text-[11px] text-slate-400 mt-0.5">{{ __('Issued via ECPay in the background after submission.') }}</span>
</span>
</label>
<div x-show="issueInvoice" x-cloak class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3 pl-1">
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Invoice Type') }}</label>
<x-searchable-select name="manual_invoice_type" id="manual-invoice-type"
:has-search="false" x-model="invoiceType" @change="invoiceType = $event.target.value">
<option value="b2c" data-title="{{ __('B2C (member carrier)') }}">{{ __('B2C (member carrier)') }}</option>
<option value="taxid" data-title="{{ __('Company Tax ID') }}">{{ __('Company Tax ID') }}</option>
<option value="mobile" data-title="{{ __('Mobile barcode carrier') }}">{{ __('Mobile barcode carrier') }}</option>
<option value="citizen" data-title="{{ __('Citizen certificate carrier') }}">{{ __('Citizen certificate carrier') }}</option>
<option value="donation" data-title="{{ __('Donation') }}">{{ __('Donation') }}</option>
</x-searchable-select>
</div>
<div class="space-y-2" x-show="invoiceType !== 'b2c'">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1" x-text="invoiceValueLabel()"></label>
<input type="text" x-model="invoiceValue" class="luxury-input w-full text-sm py-2.5 px-4">
</div>
</div>
</div>
{{-- 備註 --}}
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Remark') }}</label>
<textarea x-model="remark" rows="2" maxlength="1000" placeholder="{{ __('Enter remark (optional)') }}"
class="luxury-input w-full text-sm py-2.5 px-4 resize-none"></textarea>
</div>
</div>
{{-- Footer --}}
<div class="flex items-center justify-end gap-3 px-6 sm:px-8 py-4 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/40">
<button type="button" @click="show = false"
class="px-5 py-2.5 rounded-xl text-sm font-bold text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all">
{{ __('Cancel') }}
</button>
<button type="button" @click="submit()" :disabled="submitting"
class="px-6 py-2.5 rounded-xl text-sm font-black text-white bg-cyan-500 hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 flex items-center gap-2">
<svg x-show="submitting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span x-text="submitting ? '{{ __('Submitting...') }}' : '{{ __('Create Order') }}'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -34,7 +34,6 @@
'pending' => 'pending',
'paid' => 'active',
'completed' => 'active',
'awaiting_pickup' => 'pending',
'failed' => 'error',
'abandoned' => 'disabled',
'cancelled' => 'disabled',
@ -44,9 +43,8 @@
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
'awaiting_pickup' => __('Awaiting Pickup'),
'failed' => __('Failed'),
'abandoned' => __('Unpaid'),
'abandoned' => __('Abandoned'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
@ -65,10 +63,6 @@
<div class="space-y-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Dispense Status') }}</p>
<div>
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-sm font-extrabold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -78,7 +72,6 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="sm" />
@endif
</div>
</div>
</div>
@ -104,8 +97,7 @@
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Machine Flow ID') }}</p>
{{-- 顯示去前綴序號的乾淨流水號DB 仍存完整 serial+flow_id 唯一值供後台判斷) --}}
<p class="text-xs font-black text-slate-400 dark:text-slate-500 font-mono uppercase tracking-widest">{{ $order->display_flow_id }}</p>
<p class="text-xs font-black text-slate-400 dark:text-slate-500 font-mono uppercase tracking-widest">{{ $order->flow_id }}</p>
</div>
</div>
</section>
@ -235,10 +227,6 @@
<p class="text-base font-extrabold text-slate-800 dark:text-white truncate tracking-tight">{{ $item->product_name }}</p>
<div class="flex items-center gap-3 mt-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">${{ number_format($item->price, 0) }} × {{ $item->quantity }}</span>
@if(!empty($item->slot_no))
{{-- 消費者選擇的貨道/格子(不管出貨成功失敗都會記錄) --}}
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $item->slot_no }}</span>
@endif
</div>
</div>
<div class="text-right">

View File

@ -29,34 +29,6 @@
</x-searchable-select>
</div>
{{-- 付款狀態 --}}
<div class="w-full lg:w-44">
{{-- 不傳 placeholder避免產生 value=" " 的空值選項;「所有付款狀態」改為明確的 value=all 選項,預設為「已完成」 --}}
<x-searchable-select name="status" :selected="$filters['status']"
:has-search="false" @change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="all" {{ $filters['status']==='all' ? 'selected' : '' }} data-title="{{ __('All Payment Statuses') }}">{{ __('All Payment Statuses') }}</option>
<option value="pending" {{ $filters['status']==='pending' ? 'selected' : '' }} data-title="{{ __('Pending') }}">{{ __('Pending') }}</option>
<option value="paid" {{ $filters['status']==='paid' ? 'selected' : '' }} data-title="{{ __('Paid') }}">{{ __('Paid') }}</option>
<option value="completed" {{ $filters['status']==='completed' ? 'selected' : '' }} data-title="{{ __('Completed') }}">{{ __('Completed') }}</option>
<option value="awaiting_pickup" {{ $filters['status']==='awaiting_pickup' ? 'selected' : '' }} data-title="{{ __('Awaiting Pickup') }}">{{ __('Awaiting Pickup') }}</option>
<option value="failed" {{ $filters['status']==='failed' ? 'selected' : '' }} data-title="{{ __('Failed') }}">{{ __('Failed') }}</option>
<option value="abandoned" {{ $filters['status']==='abandoned' ? 'selected' : '' }} data-title="{{ __('Unpaid') }}">{{ __('Unpaid') }}</option>
<option value="cancelled" {{ $filters['status']==='cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
<option value="refunded" {{ $filters['status']==='refunded' ? 'selected' : '' }} data-title="{{ __('Refunded') }}">{{ __('Refunded') }}</option>
</x-searchable-select>
</div>
{{-- 出貨狀態 --}}
<div class="w-full lg:w-44">
<x-searchable-select name="delivery_status" :placeholder="__('All Dispense Statuses')"
:selected="$filters['delivery_status']" :has-search="false"
@change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="1" {{ (string)$filters['delivery_status']==='1' ? 'selected' : '' }} data-title="{{ __('Dispense Success') }}">{{ __('Dispense Success') }}</option>
<option value="2" {{ (string)$filters['delivery_status']==='2' ? 'selected' : '' }} data-title="{{ __('Partial Dispense Success') }}">{{ __('Partial Dispense Success') }}</option>
<option value="0" {{ (string)$filters['delivery_status']==='0' ? 'selected' : '' }} data-title="{{ __('Dispense Failed') }}">{{ __('Dispense Failed') }}</option>
</x-searchable-select>
</div>
{{-- 開始時間 --}}
<div class="relative group w-full lg:w-56 lg:flex-none"
x-data="{ fp: null }"
@ -104,18 +76,6 @@
</div>
<div class="flex items-center gap-2 ml-auto lg:ml-0 shrink-0">
@if(auth()->user()->isSystemAdmin())
{{-- 手動補單:機台斷線/漏報時人工補登銷售紀錄(僅系統管理員) --}}
<button type="button"
onclick="window.dispatchEvent(new CustomEvent('open-manual-order'))"
class="px-3.5 py-2.5 rounded-xl bg-slate-800 dark:bg-slate-700 text-white hover:bg-slate-900 dark:hover:bg-slate-600 shadow-lg transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Manual Order Entry') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
<span class="hidden sm:inline">{{ __('Manual Entry') }}</span>
</button>
@endif
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95"
title="{{ __('Search') }}">
@ -215,10 +175,6 @@
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Dispense Status') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Pickup Code') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Invoice Number') }}
@ -242,14 +198,9 @@
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors cursor-pointer">
{{ $order->order_no }}
</span>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{{ $order->created_at->format('Y-m-d H:i:s') }}
</span>
@if($order->is_manual)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-black text-amber-600 dark:text-amber-400 uppercase tracking-widest">{{ __('Manual') }}</span>
@endif
</div>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">
{{ $order->created_at->format('Y-m-d H:i:s') }}
</span>
</div>
</td>
<td class="px-6 py-6">
@ -292,18 +243,6 @@
{{ __('Discounted') }} ${{ number_format($order->discount_amount, 0) }}
</span>
@endif
@if($order->cash_received_summary)
<span class="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold mt-1 tracking-tight leading-relaxed">
{{ $order->cash_received_summary }}
</span>
@endif
@if((int) $order->payment_type === 6 && !empty($order->member_barcode))
{{-- 取貨碼交易:於支付金額欄換行加註取貨碼序號 --}}
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 mt-1.5 flex items-center gap-1">
{{ __('Pickup Code') }}:
<span class="font-mono tracking-tighter normal-case text-slate-700 dark:text-slate-200">{{ $order->member_barcode }}</span>
</span>
@endif
@if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard)
<span class="text-[10px] text-cyan-600 dark:text-cyan-400 font-extrabold mt-1.5 flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
@ -328,10 +267,8 @@
'pending' => ['label' => __('Pending'), 'color' => 'amber'],
'paid' => ['label' => __('Paid'), 'color' => 'emerald'],
'completed' => ['label' => __('Completed'), 'color' => 'emerald'],
'awaiting_pickup' => ['label' => __('Awaiting Pickup'), 'color' => 'amber'],
'awaiting_pickup' => ['label' => __('Awaiting Pickup'), 'color' => 'amber'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'abandoned' => ['label' => __('Unpaid'), 'color' => 'slate'],
'abandoned' => ['label' => __('Abandoned'), 'color' => 'slate'],
'cancelled' => ['label' => __('Cancelled'), 'color' => 'slate'],
'refunded' => ['label' => __('Refunded'), 'color' => 'rose'],
];
@ -340,10 +277,6 @@
<x-status-badge :color="$s['color']" :label="$s['label']" size="xs" />
</td>
<td class="px-6 py-6 whitespace-nowrap">
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -353,37 +286,6 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="xs" />
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$qualifiesForPickup = in_array($order->status, ['completed','paid'], true) && in_array((int) $order->delivery_status, [0, 2], true);
$orderPickupCodes = $order->relationLoaded('compensationCodes') ? $order->compensationCodes : collect();
@endphp
@if($orderPickupCodes->count())
<div class="flex flex-col gap-1">
@foreach($orderPickupCodes as $cc)
<span class="inline-flex items-center gap-1.5 text-[11px] font-black">
<span class="font-mono tracking-tighter px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-slate-700 dark:text-slate-200">{{ $cc->code }}</span>
@if($cc->status === 'active' && $cc->usage_count < $cc->usage_limit)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] text-amber-600 dark:text-amber-400 uppercase tracking-widest">{{ __('Pending Use') }}</span>
@else
<span class="px-1.5 py-0.5 rounded bg-slate-500/10 border border-slate-500/20 text-[9px] text-slate-500 uppercase tracking-widest">{{ __('Used') }}</span>
@endif
</span>
@endforeach
</div>
@elseif($qualifiesForPickup)
<button type="button" @click="generatePickupCode({{ $order->id }})"
title="{{ __('Generate Pickup Code') }}"
class="w-8 h-8 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20 flex items-center justify-center transition-all active:scale-95 border border-emerald-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
@else
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
@ -429,8 +331,8 @@
</tr>
@empty
<tr>
<td colspan="10" class="py-20 text-center">
<x-empty-state mode="table" colspan="10" :message="__('No transaction orders found')" />
<td colspan="9" class="py-20 text-center">
<x-empty-state mode="table" colspan="9" :message="__('No transaction orders found')" />
</td>
</tr>
@endforelse
@ -449,7 +351,7 @@
'paid' => ['label' => __('Paid'), 'color' => 'emerald'],
'completed' => ['label' => __('Completed'), 'color' => 'emerald'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'abandoned' => ['label' => __('Unpaid'), 'color' => 'slate'],
'abandoned' => ['label' => __('Abandoned'), 'color' => 'slate'],
'cancelled' => ['label' => __('Cancelled'), 'color' => 'slate'],
'refunded' => ['label' => __('Refunded'), 'color' => 'rose'],
];
@ -472,9 +374,6 @@
<h3 @click="openDetail({{ $order->id }})"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $order->order_no }}
@if($order->is_manual)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-black text-amber-600 dark:text-amber-400 uppercase tracking-widest align-middle">{{ __('Manual') }}</span>
@endif
</h3>
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
@ -512,11 +411,6 @@
<span class="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800/50 text-slate-500">{{
$paymentTypes[$order->payment_type] ?? '??' }}</span>
</p>
@if($order->cash_received_summary)
<div class="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold mt-1 leading-relaxed">
{{ $order->cash_received_summary }}
</div>
@endif
@if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard)
<div class="text-[10px] text-cyan-600 dark:text-cyan-400 font-extrabold mt-1 flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
@ -557,10 +451,6 @@
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{
__('Dispense Status') }}</p>
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -570,38 +460,6 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="xs" />
@endif
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Pickup Code') }}</p>
@php
$qualifiesForPickup = in_array($order->status, ['completed','paid'], true) && in_array((int) $order->delivery_status, [0, 2], true);
$orderPickupCodes = $order->relationLoaded('compensationCodes') ? $order->compensationCodes : collect();
@endphp
@if($orderPickupCodes->count())
<div class="flex flex-col gap-1">
@foreach($orderPickupCodes as $cc)
<span class="inline-flex items-center gap-1.5 text-[11px] font-black">
<span class="font-mono tracking-tighter px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-slate-700 dark:text-slate-200">{{ $cc->code }}</span>
@if($cc->status === 'active' && $cc->usage_count < $cc->usage_limit)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] text-amber-600 dark:text-amber-400 uppercase tracking-widest">{{ __('Pending Use') }}</span>
@else
<span class="px-1.5 py-0.5 rounded bg-slate-500/10 border border-slate-500/20 text-[9px] text-slate-500 uppercase tracking-widest">{{ __('Used') }}</span>
@endif
</span>
@endforeach
</div>
@elseif($qualifiesForPickup)
<button type="button" @click="generatePickupCode({{ $order->id }})"
class="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20 text-xs font-black uppercase tracking-widest transition-all active:scale-95 border border-emerald-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ __('Generate Pickup Code') }}
</button>
@else
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@endif
</div>
</div>

View File

@ -12,9 +12,6 @@
passCodeName: '',
expiresDays: 7,
customCode: '',
quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
status: '{{ request('status', '') }}',
showQrModal: false,
activeQrCode: '',
@ -148,7 +145,7 @@
window.showToast('{{ __('Please enter a description') }}', 'error');
return;
}
if (parseInt(this.quantity) <= 1 && !this.customCode.trim()) {
if (!this.customCode.trim()) {
window.showToast('{{ __('Please enter or generate a pass code') }}', 'error');
return;
}
@ -197,15 +194,12 @@
this.selectedMachine = '';
this.passCodeName = '';
this.expiresDays = 7;
this.quantity = 1;
this.generateRandomCode();
this.syncSelect('modal-pass-machine', '');
}
});
}
} " @ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@include('admin.sales.partials.batch-result-banner')
{{-- Page Header --}}
<x-page-header :title="__('Pass Codes')"
:subtitle="__('Manage multi-use authorization codes for testing and maintenance')">
@ -308,42 +302,6 @@
</div>
</div>
{{-- Quantity (Batch Generate) --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})
</label>
<div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
<p class="text-[10px] font-bold text-amber-500 pl-1" x-show="parseInt(quantity) > 1" x-cloak>
{{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }}
</p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
@ -359,10 +317,8 @@
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
:disabled="parseInt(quantity) > 1" :required="parseInt(quantity) <= 1"
:class="parseInt(quantity) > 1 ? 'opacity-40 cursor-not-allowed' : ''"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5"
maxlength="12">
maxlength="12" required>
</div>
<div class="space-y-4">
@ -421,13 +377,6 @@
</div>
</div>
{{-- 只能使用一次:勾選 usage_limit=1;不勾 無限次(維持原行為) --}}
<label class="flex items-center gap-3 px-1 cursor-pointer select-none">
<input type="checkbox" name="single_use" value="1"
class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-cyan-500 focus:ring-cyan-500 cursor-pointer">
<span class="text-xs font-black text-slate-500 uppercase tracking-widest">{{ __('Single use only') }}</span>
</label>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }}

View File

@ -109,7 +109,7 @@
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$code->machine?->name ?? '-' }}</div>
$code->machine->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($code->expires_at)
@ -241,7 +241,7 @@
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
$code->machine?->name ?? '-' }}</p>
$code->machine->name }}</p>
</div>
<div>
<p

View File

@ -10,450 +10,172 @@
'abandoned' => __('Abandoned'),
];
$statusColors = [
'awaiting_pickup' => 'bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400',
'completed' => 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400',
'failed' => 'bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400',
'pending' => 'bg-slate-500/10 border border-slate-500/20 text-slate-500',
'abandoned' => 'bg-slate-500/10 border border-slate-500/20 text-slate-500',
'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-4 pb-20" x-data="pharmacyPickupManager()">
<div class="space-y-6 pb-20">
{{-- Page Header --}}
<x-page-header
:title="__('menu.sales.pharmacy-pickup')"
:subtitle="__('Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.')">
@if($machines->isNotEmpty())
<button @click="openCreateModal()"
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
</svg>
<span class="text-sm sm:text-base">{{ __('Create Pharmacy Pickup Order') }}</span>
</button>
@endif
</x-page-header>
<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>
{{-- Flash / Errors --}}
@if(session('success'))
<div class="p-5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 rounded-2xl flex items-center gap-3 font-bold text-sm animate-luxury-in">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('success') }}
</div>
<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="p-5 bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 rounded-2xl flex items-center gap-3 font-bold text-sm animate-luxury-in">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01M5.07 19h13.86a2 2 0 001.74-3L13.74 4a2 2 0 00-3.48 0L3.33 16a2 2 0 001.74 3z"/></svg>
{{ session('error') }}
</div>
<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="p-5 bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 rounded-2xl text-sm animate-luxury-in">
<ul class="list-disc ps-5 space-y-1 font-bold">
<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
@if($machines->isEmpty())
<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 rounded-2xl flex items-start gap-4 font-bold text-sm">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01M5.07 19h13.86a2 2 0 001.74-3L13.74 4a2 2 0 00-3.48 0L3.33 16a2 2 0 001.74 3z"/></svg>
{{ __('No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).') }}
</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="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<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>
{{-- Desktop Table --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Order No.') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Items') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Pickup Code') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Created') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
<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/80">
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
@forelse($orders as $order)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200 {{ session('created_order_id') == $order->id ? 'bg-emerald-50/60 dark:bg-emerald-500/5' : '' }}">
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-base font-black font-mono text-slate-800 dark:text-slate-100 tracking-tight">{{ $order->order_no }}</div>
@if($order->pricing_slip_no)
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-0.5">{{ __('Pricing Slip No.') }}: {{ $order->pricing_slip_no }}</div>
@endif
<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="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $order->machine?->name ?? '-' }}</div>
<div class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-[0.2em]">{{ $order->machine?->serial_no }}</div>
<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="px-6 py-6">
<div class="space-y-0.5">
@foreach($order->items as $it)
<div class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $it->product_name }} <span class="text-cyan-600 dark:text-cyan-400 font-black">× {{ $it->quantity }}</span></div>
@endforeach
</div>
<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="px-6 py-6 text-center whitespace-nowrap">
@if($order->pickupCode?->code)
<span class="text-base font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-3 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20">{{ $order->pickupCode->code }}</span>
@else
<span class="text-slate-300 dark:text-slate-600">-</span>
@endif
</td>
<td class="px-6 py-6 text-center whitespace-nowrap">
<span class="inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest {{ $statusColors[$order->status] ?? 'bg-slate-500/10 border border-slate-500/20 text-slate-500' }}">{{ $statusLabels[$order->status] ?? $order->status }}</span>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono">{{ $order->created_at?->format('Y-m-d H:i') }}</div>
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ $order->creator?->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
<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="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all"
title="{{ __('Print') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z"/></svg>
</a>
<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 id="pp-cancel-desktop-{{ $order->id }}" method="POST" action="{{ route('admin.sales.pharmacy-pickup.cancel', $order) }}">
<form method="POST" action="{{ route('admin.sales.pharmacy-pickup.cancel', $order) }}" onsubmit="return confirm('{{ __('Cancel this pharmacy pickup order?') }}')">
@csrf
<button type="button" @click="confirmCancel('pp-cancel-desktop-{{ $order->id }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 border border-transparent hover:border-rose-500/20 transition-all"
title="{{ __('Cancel') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38"/></svg>
</button>
<button type="submit" class="text-xs font-bold text-rose-500 hover:text-rose-600">{{ __('Cancel') }}</button>
</form>
@endif
@if($order->status !== 'awaiting_pickup' && !($order->status !== 'failed' && $order->pickupCode))
<span class="text-slate-300 dark:text-slate-600 pr-2">-</span>
@if($order->status === 'failed' && !($order->pickupCode))
<span class="text-slate-300">-</span>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="7" :message="__('No pharmacy pickup orders yet.')" />
<tr><td colspan="8" class="py-8 text-center text-slate-400">{{ __('No pharmacy pickup orders yet.') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- Mobile Card Grid --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse($orders as $order)
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group {{ session('created_order_id') == $order->id ? 'ring-2 ring-emerald-500/30' : '' }}">
{{-- Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<div class="min-w-0">
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate tracking-tight font-mono">{{ $order->order_no }}</h3>
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">{{ $order->machine?->name ?? '-' }}</p>
</div>
</div>
<span class="inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest shrink-0 {{ $statusColors[$order->status] ?? 'bg-slate-500/10 border border-slate-500/20 text-slate-500' }}">{{ $statusLabels[$order->status] ?? $order->status }}</span>
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Pickup Code') }}</p>
<p class="text-sm font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest">{{ $order->pickupCode?->code ?? '-' }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Pricing Slip No.') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $order->pricing_slip_no ?: '-' }}</p>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Items') }}</p>
<div class="space-y-0.5">
@foreach($order->items as $it)
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $it->product_name }} <span class="text-cyan-600 dark:text-cyan-400 font-black">× {{ $it->quantity }}</span></p>
@endforeach
</div>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Created') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono">{{ $order->created_at?->format('Y-m-d H:i') }} <span class="text-slate-400 font-sans">· {{ $order->creator?->name }}</span></p>
</div>
</div>
{{-- Actions --}}
@if(($order->status !== 'failed' && $order->pickupCode) || $order->status === 'awaiting_pickup')
<div class="flex items-center gap-3">
@if($order->status !== 'failed' && $order->pickupCode)
<a href="{{ route('admin.sales.pharmacy-pickup.print', $order) }}" target="_blank"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247"/></svg>
{{ __('Print') }}
</a>
@endif
@if($order->status === 'awaiting_pickup')
<form id="pp-cancel-mobile-{{ $order->id }}" method="POST" action="{{ route('admin.sales.pharmacy-pickup.cancel', $order) }}" class="flex-1">
@csrf
<button type="button" @click="confirmCancel('pp-cancel-mobile-{{ $order->id }}')"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79"/></svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
@endif
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pharmacy pickup orders yet.')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $orders->appends(request()->query())->links('vendor.pagination.luxury') }}
</div>
<div>{{ $orders->links() }}</div>
</div>
{{-- Create Pharmacy Pickup Order Modal --}}
<div x-show="showCreateModal" class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak style="display:none;">
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-3xl sm:w-full overflow-visible">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Create Pharmacy Pickup Order') }}</h3>
<button @click="showCreateModal = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form action="{{ route('admin.sales.pharmacy-pickup.store') }}" method="POST" class="space-y-6"
@submit.prevent="validateAndSubmit" x-ref="createForm">
@csrf
<input type="hidden" name="machine_id" :value="selectedMachine">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{{-- Machine --}}
<div class="space-y-2 relative focus-within:z-[60]">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Select Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="machine_select" id="pp-machine"
placeholder="{{ __('-- Select Machine --') }}" x-model="selectedMachine"
@change="selectedMachine = $event.target.value; fetchProducts()">
@foreach($machines as $m)
<option value="{{ $m->id }}" data-title="{{ $m->name }} ({{ $m->serial_no }})">{{ $m->name }} ({{ $m->serial_no }})</option>
@endforeach
</x-searchable-select>
</div>
{{-- Pricing Slip No. --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Pricing Slip No.') }} <span class="text-slate-300 normal-case font-bold">({{ __('optional') }})</span></label>
<input type="text" name="pricing_slip_no" x-model="pricingSlipNo" maxlength="64"
@keydown.enter.prevent
class="luxury-input w-full py-3 px-4 text-sm" placeholder="{{ __('e.g. the hospital pricing slip number') }}">
</div>
</div>
{{-- Medicine selection --}}
<div class="space-y-3">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Medicine') }} <span class="text-rose-500">*</span></label>
{{-- Empty: no machine selected --}}
<div x-show="!selectedMachine" class="rounded-[2rem] border border-dashed border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/30 p-10 text-center">
<p class="text-sm font-bold text-slate-400">{{ __('Please select a machine') }}</p>
</div>
{{-- Loading --}}
<div x-show="selectedMachine && loadingProducts" class="rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/30 p-10 flex items-center justify-center">
<x-luxury-spinner show="loadingProducts" />
</div>
{{-- No products --}}
<div x-show="selectedMachine && !loadingProducts && products.length === 0" class="rounded-[2rem] border border-amber-500/20 bg-amber-500/5 p-8 text-center">
<p class="text-sm font-bold text-amber-600 dark:text-amber-400">{{ __('This machine currently has no medicine in stock.') }}</p>
</div>
{{-- Product table --}}
<div x-show="selectedMachine && !loadingProducts && products.length > 0"
class="overflow-hidden rounded-[2rem] border border-slate-100 dark:border-slate-800">
<table class="w-full text-sm">
<thead class="bg-slate-50/70 dark:bg-slate-900/40">
<tr>
<th class="py-3 px-4 text-left w-10"></th>
<th class="py-3 px-4 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Medicine') }}</th>
<th class="py-3 px-4 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Spec') }}</th>
<th class="py-3 px-4 text-center text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Available') }}</th>
<th class="py-3 px-4 text-center text-[10px] font-black text-slate-400 uppercase tracking-widest w-40">{{ __('Quantity') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
<template x-for="p in products" :key="p.product_id">
<tr class="transition-colors duration-200"
:class="p.checked ? 'bg-cyan-50/40 dark:bg-cyan-500/5' : 'hover:bg-slate-50/50 dark:hover:bg-white/[0.02]'">
<td class="py-3 px-4 text-center">
<input type="checkbox" x-model="p.checked"
class="w-4 h-4 text-cyan-500 rounded border-slate-300 dark:border-slate-600 focus:ring-cyan-500/30 cursor-pointer">
</td>
<td class="py-3 px-4 font-bold text-slate-700 dark:text-slate-200" x-text="p.name"></td>
<td class="py-3 px-4 text-slate-500" x-text="p.spec || '-'"></td>
<td class="py-3 px-4 text-center">
<span class="font-black text-slate-700 dark:text-slate-200" x-text="p.available"></span>
<template x-if="p.reserved > 0">
<span class="block text-[10px] font-bold text-slate-400" x-text="'({{ __('physical') }} ' + p.physical + ' {{ __('reserved') }} ' + p.reserved + ')'"></span>
</template>
</td>
<td class="py-3 px-4">
{{-- Luxury +/- counter --}}
<div class="flex items-center h-10 mx-auto rounded-xl border border-slate-200/70 dark:border-slate-700/50 bg-white dark:bg-slate-900/50 overflow-hidden w-[140px] transition-all"
:class="{ 'opacity-40 pointer-events-none': !p.checked }">
<button type="button" @click="p.qty > 1 ? p.qty-- : 1"
class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" x-model.number="p.qty" readonly
class="flex-1 w-full min-w-0 bg-transparent border-none text-center text-base font-black text-slate-800 dark:text-white focus:ring-0 p-0">
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="p.qty < p.available ? p.qty++ : p.available"
class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Submitted only when checked (empty name = skipped) --}}
<input type="hidden" :name="p.checked ? 'products[]' : ''" :value="p.product_id">
<input type="hidden" :name="p.checked ? `qty[${p.product_id}]` : ''" :value="p.qty">
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="text-[10px] font-bold text-slate-400 pl-1">{{ __('(deducted by issued pickup orders)') }}</p>
</div>
<div class="flex justify-end gap-x-4 pt-4">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-12 flex items-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
{{ __('Create & Generate QR') }}
</button>
</div>
</form>
</div>
</div>
</div>
{{-- Confirm Cancel Modal --}}
<x-confirm-modal alpineVar="showCancelModal" :title="__('Cancel Pickup Code')"
:message="__('Cancel this pharmacy pickup order?')"
:confirmText="__('Yes, Cancel')" confirmAction="submitCancel()" confirmColor="rose" iconType="danger" />
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('pharmacyPickupManager', () => ({
showCreateModal: false,
selectedMachine: '',
pricingSlipNo: '',
products: [],
loadingProducts: false,
showCancelModal: false,
cancelTargetForm: null,
openCreateModal() {
this.selectedMachine = '';
this.pricingSlipNo = '';
this.products = [];
this.showCreateModal = true;
this.syncSelect('pp-machine', ' ');
},
async fetchProducts() {
if (!this.selectedMachine) {
this.products = [];
return;
}
this.loadingProducts = true;
try {
const res = await fetch(`/admin/sales/pharmacy-pickup/${this.selectedMachine}/products-ajax`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
});
const data = await res.json();
this.products = (data.products || []).map(p => ({
...p,
checked: false,
qty: 1,
}));
} catch (e) {
console.error('Error fetching products:', e);
window.showToast?.('{{ __("Loading failed") }}', 'error');
this.products = [];
} finally {
this.loadingProducts = false;
}
},
validateAndSubmit() {
if (!this.selectedMachine || this.selectedMachine.toString().trim() === '') {
window.showToast?.('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.products.some(p => p.checked)) {
window.showToast?.('{{ __("Please select at least one medicine with quantity.") }}', 'error');
return;
}
this.$refs.createForm.submit();
},
confirmCancel(formId) {
this.cancelTargetForm = formId;
this.showCancelModal = true;
},
submitCancel() {
if (this.cancelTargetForm) {
const form = document.getElementById(this.cancelTargetForm);
if (form) form.submit();
}
this.showCancelModal = false;
},
syncSelect(id, value) {
this.$nextTick(() => {
const el = document.getElementById(id);
if (el) {
const valStr = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = valStr;
window.HSSelect?.getInstance(el)?.setValue(valStr);
}
});
},
init() {
this.$watch('selectedMachine', (val) => {
this.syncSelect('pp-machine', val);
});
}
}));
});
</script>
@endsection

View File

@ -2,9 +2,9 @@
@php
$slotSelectConfig = [
"placeholder" => __("Select Product"),
"placeholder" => __("Select Slot"),
"hasSearch" => true,
"searchPlaceholder" => __("Search Product..."),
"searchPlaceholder" => __("Search Slot..."),
"isHidePlaceholder" => false,
"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg
focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200
@ -37,7 +37,7 @@ transition-all duration-300",
<div class="space-y-2 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))"
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@include('admin.sales.partials.batch-result-banner')
{{-- Page Header --}}
<x-page-header :title="__('Pickup Codes')"
@ -234,7 +234,7 @@ transition-all duration-300",
__('Target Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="machine_id" id="modal-pickup-machine"
placeholder="{{ __('Please select a machine') }}" x-model="selectedMachine"
@change="selectedMachine = $event.target.value; fetchCompanyProducts()">
@change="selectedMachine = $event.target.value; fetchSlots()">
@foreach($machines as $machine)
<option value="{{ $machine->id }}"
data-title="{{ $machine->name }} ({{ $machine->serial_no }})">{{ $machine->name }}
@ -244,7 +244,7 @@ transition-all duration-300",
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Select Product') }} <span class="text-rose-500">*</span></label>
__('Select Slot') }} <span class="text-rose-500">*</span></label>
<div class="relative">
<div id="slot-select-wrapper" class="relative">
{{-- Rebuilt by Alpine --}}
@ -253,10 +253,6 @@ transition-all duration-300",
<x-luxury-spinner show="loadingSlots" />
</div>
</div>
<p x-show="productNotOnMachine" x-cloak class="text-[11px] font-bold text-amber-500 pl-1 flex items-center gap-1">
<svg class="size-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 0 0-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
{{ __('This product is not stocked on the selected machine; the code may not be redeemable until restocked.') }}
</p>
</div>
<div class="space-y-2">
@ -295,41 +291,6 @@ transition-all duration-300",
</div>
</div>
{{-- Quantity (Batch Generate) 獨立整列,避免與使用次數上限擠在同一列重疊。上限/快捷選項依角色分級 --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})</label>
<div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
<p class="text-[10px] font-bold text-amber-500 pl-1" x-show="parseInt(quantity) > 1" x-cloak>
{{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }}
</p>
</div>
{{-- Pickup Code Preview Section --}}
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
@ -346,10 +307,8 @@ transition-all duration-300",
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
:disabled="parseInt(quantity) > 1" :required="parseInt(quantity) <= 1"
:class="parseInt(quantity) > 1 ? 'opacity-40 cursor-not-allowed' : ''"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5"
maxlength="12">
maxlength="12" required>
</div>
{{-- Validity Period Section (Flexible) --}}
@ -535,21 +494,16 @@ transition-all duration-300",
tabLoading: null,
showCreateModal: false,
selectedMachine: '',
selectedProduct: '',
selectedSlot: '',
expiresHours: 24,
maxHours: 168,
expiryMode: 'hours',
customExpiry: '',
usageLimit: 1,
quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
showQrModal: false,
activeQrCode: '',
activeTicketUrl: '',
companyProducts: [],
machineProductIds: [],
productNotOnMachine: false,
slots: [],
tabLoading: null,
loadingSlots: false,
showCancelModal: false,
@ -645,22 +599,18 @@ transition-all duration-300",
return this.calculateExpiry();
},
// 取貨碼綁商品:下拉以「機台所屬公司的商品」為準(非僅機台貨道);
// machineProductIds 用來標示/提示「此機台目前未上架」的商品。
async fetchCompanyProducts() {
async fetchSlots() {
if (!this.selectedMachine) {
this.companyProducts = [];
this.machineProductIds = [];
this.slots = [];
return;
}
this.loadingSlots = true;
try {
const response = await fetch(`/admin/sales/pickup-codes/company-products/${this.selectedMachine}`);
const response = await fetch(`/admin/machines/${this.selectedMachine}/slots-ajax`);
const data = await response.json();
this.companyProducts = data.products || [];
this.machineProductIds = (data.machine_product_ids || []).map(id => String(id));
this.slots = data.slots || [];
} catch (error) {
console.error('Error fetching company products:', error);
console.error('Error fetching slots:', error);
} finally {
this.loadingSlots = false;
}
@ -671,8 +621,8 @@ transition-all duration-300",
window.showToast('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.selectedProduct) {
window.showToast('{{ __("Please select a product") }}', 'error');
if (!this.selectedSlot) {
window.showToast('{{ __("Please select a slot") }}', 'error');
return;
}
this.$refs.createForm.submit();
@ -724,8 +674,6 @@ transition-all duration-300",
this.showCancelModal = false;
},
// 取貨碼改綁商品:下拉改列「此機台有上架的商品」(由貨道清單去重),
// 提交 product_id貨道改由 App 刷碼當下依即時庫存挑選。
updateSlotSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('slot-select-wrapper');
@ -739,15 +687,15 @@ transition-all duration-300",
if (!this.selectedMachine) {
const dummy = document.createElement('div');
dummy.className = 'luxury-input opacity-50 cursor-not-allowed flex items-center justify-between';
dummy.innerHTML = `<span>{{ __("Select Product") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
dummy.innerHTML = `<span>{{ __("Select Slot") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
wrapper.appendChild(dummy);
return;
}
const selectEl = document.createElement('select');
selectEl.name = 'product_id';
selectEl.name = 'slot_no';
// Remove required to allow our custom validation toast to trigger
selectEl.id = 'modal-pickup-product-' + Date.now();
selectEl.id = 'modal-pickup-slot-' + Date.now();
selectEl.className = 'hidden';
// 清理配置字串中的換行符
@ -758,32 +706,22 @@ transition-all duration-300",
const placeholderOpt = document.createElement('option');
placeholderOpt.value = "";
placeholderOpt.textContent = "{{ __('Select Product') }}";
placeholderOpt.setAttribute('data-title', "{{ __('Select Product') }}");
placeholderOpt.textContent = "{{ __('Select Slot') }}";
placeholderOpt.setAttribute('data-title', "{{ __('Select Slot') }}");
selectEl.appendChild(placeholderOpt);
// 列出機台所屬公司的商品;機台目前未上架者於名稱後標示提示
this.companyProducts.forEach(product => {
this.slots.forEach(slot => {
const opt = document.createElement('option');
opt.value = product.id;
const onMachine = this.machineProductIds.includes(String(product.id));
const text = onMachine ? product.name : `${product.name}{{ __('Not stocked on this machine') }}`;
opt.value = slot.slot_no;
const text = `${slot.slot_no} - ${slot.product ? slot.product.name : 'Empty'}`;
opt.textContent = text;
opt.setAttribute('data-title', text);
if (this.selectedProduct == product.id) opt.selected = true;
if (this.selectedSlot == slot.slot_no) opt.selected = true;
selectEl.appendChild(opt);
});
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', e => {
this.selectedProduct = e.target.value;
// 選到機台未上架的商品 → 提示,但仍允許產碼
this.productNotOnMachine = !!this.selectedProduct
&& !this.machineProductIds.includes(String(this.selectedProduct));
if (this.productNotOnMachine) {
window.showToast('{{ __("This product is not stocked on the selected machine; the code may not be redeemable until restocked.") }}', 'warning');
}
});
selectEl.addEventListener('change', e => { this.selectedSlot = e.target.value; });
if (window.HSStaticMethods?.autoInit) {
window.HSStaticMethods.autoInit(['select']);
@ -819,14 +757,13 @@ transition-all duration-300",
});
});
this.$watch('companyProducts', () => this.updateSlotSelect());
this.$watch('slots', () => this.updateSlotSelect());
this.$watch('selectedMachine', (val) => {
this.syncSelect('modal-pickup-machine', val);
this.selectedProduct = ''; // Reset product when machine changes
this.productNotOnMachine = false;
this.fetchCompanyProducts();
this.selectedSlot = ''; // Reset slot when machine changes
this.fetchSlots();
});
this.$watch('selectedProduct', (val) => {
this.$watch('selectedSlot', (val) => {
// Selection toast removed per user request
});
this.$watch('expiryMode', (mode) => {
@ -838,18 +775,15 @@ transition-all duration-300",
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
this.selectedProduct = '';
this.productNotOnMachine = false;
this.selectedSlot = '';
this.usageLimit = 1;
this.quantity = 1;
this.expiryMode = 'hours';
this.expiresHours = 24;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setMinutes(0);
this.customExpiry = this.formatDate(tomorrow);
this.companyProducts = [];
this.machineProductIds = [];
this.slots = [];
this.generateRandomCode();
this.updateSlotSelect();
}

View File

@ -80,7 +80,7 @@
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Product') }}
{{ __('Product / Slot') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
@ -117,33 +117,23 @@
<td class="px-6 py-6">
<div
class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->machine?->name ?? '-' }}</div>
{{ $code->machine->name }}</div>
<div
class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">
{{
$code->machine?->serial_no }}</div>
$code->machine->serial_no }}</div>
</td>
<td class="px-6 py-6">
@php
// 綁商品碼(新):直接取 product 名稱,不顯示貨道。
// 綁貨道碼(舊,向後相容):由貨道反查商品,並保留貨道資訊。
if ($code->product_id) {
$productName = $code->product?->name ?? __('Empty');
$slotLabel = null;
} else {
$slot = $code->machine?->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
$slotLabel = $code->slot_no;
}
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
@endphp
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{
$productName
}}</div>
@if($slotLabel)
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
__('Slot')
}}: {{ $slotLabel }}</div>
@endif
}}: {{ $code->slot_no }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300">
@ -261,7 +251,7 @@
@endif
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine?->name ?? '-' }}
{{ $code->machine->name }}
</p>
</div>
</div>
@ -291,24 +281,19 @@
<div
class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
@php
if ($code->product_id) {
$prodMobile = $code->product?->name ?? __('Empty');
$slotMobileLabel = null;
} else {
$slotMobile = $code->machine?->slots->where('slot_no', $code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) : __('Empty');
$slotMobileLabel = $code->slot_no;
}
@endphp
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Product') }}</p>
{{ __('Slot') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
<span class="text-cyan-600 dark:text-cyan-400">{{ $prodMobile }}</span>
@if($slotMobileLabel)
<span class="ml-1 text-[10px] text-slate-400">{{ __('Slot') }}: {{ $slotMobileLabel }}</span>
@endif
{{ $code->slot_no }}
@php
$slotMobile = $code->machine->slots->where('slot_no',
$code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) :
__('Empty');
@endphp
<span class="ml-1 text-cyan-600 dark:text-cyan-400">({{ $prodMobile
}})</span>
</p>
</div>
<div>

View File

@ -108,7 +108,7 @@
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $gift->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$gift->machine?->name ?? '-' }}</div>
$gift->machine->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<span class="text-sm font-black text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 px-3 py-1 rounded-lg border border-indigo-100 dark:border-indigo-500/20">
@ -258,7 +258,7 @@
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">
{{ $gift->machine?->name ?? '-' }}</p>
{{ $gift->machine->name }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">

View File

@ -85,13 +85,6 @@
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button type="button" @click="openExportModal()"
class="p-2.5 rounded-xl bg-emerald-500 text-white hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 group transition-all active:scale-95"
title="{{ __('Export Machine Inventory') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
</div>
</form>
</div>
@ -103,60 +96,6 @@
</div>
{{-- Detail Mode --}}
{{-- 機台庫存批次匯出 Modal --}}
<div x-show="exportModalOpen" class="fixed inset-0 z-[100] overflow-y-auto" x-cloak
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 py-8">
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="exportModalOpen = false"></div>
<div class="relative bg-white dark:bg-slate-900 rounded-3xl shadow-2xl w-full max-w-lg border border-slate-100 dark:border-slate-800 max-h-[85vh] flex flex-col">
<div class="px-8 pt-8 pb-4 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Export Machine Inventory') }}</h3>
<button @click="exportModalOpen = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="px-8 py-4 border-b border-slate-50 dark:border-slate-800/50">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" :checked="exportAllChecked" @change="toggleExportAll($event)"
class="w-4 h-4 text-emerald-500 rounded border-slate-300 focus:ring-emerald-500 dark:bg-slate-800 dark:border-slate-700">
<span class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Select All') }}
<span class="text-slate-400 font-bold">(<span x-text="exportSelectedIds.length"></span>/{{ $exportMachines->count() }})</span>
</span>
</label>
</div>
<div class="px-6 py-3 overflow-y-auto flex-1 space-y-1">
@forelse($exportMachines as $m)
<label class="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer transition-colors">
<input type="checkbox" value="{{ $m->id }}" x-model.number="exportSelectedIds"
class="w-4 h-4 text-emerald-500 rounded border-slate-300 focus:ring-emerald-500 dark:bg-slate-800 dark:border-slate-700">
<span class="flex flex-col">
<span class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ $m->name }}</span>
<span class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest">{{ $m->serial_no }}</span>
</span>
</label>
@empty
<p class="text-sm text-slate-400 text-center py-8">{{ __('No machines') }}</p>
@endforelse
</div>
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex flex-wrap justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button @click="exportModalOpen = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
{{-- 暫時隱藏 CSV 匯出(保留備用;後端仍支援 csv/zip --}}
{{--
<button @click="doExport('csv')"
class="px-5 py-2.5 rounded-xl bg-emerald-500 text-white font-black text-sm uppercase tracking-widest hover:bg-emerald-600 transition-all active:scale-95 flex items-center gap-2">
<span>📄</span> {{ __('Export to CSV') }}
</button>
--}}
<button @click="doExport('excel')"
class="px-5 py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase tracking-widest hover:bg-emerald-700 transition-all active:scale-95 flex items-center gap-2">
<span>📊</span> {{ __('Export to Excel') }}
</button>
</div>
</div>
</div>
</div>
<div x-show="viewMode === 'detail'" class="space-y-8 animate-luxury-in" x-cloak>
<!-- Statistics Dashboard -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
@ -321,32 +260,6 @@
</svg>
{{ __('Grid View') }}
</button>
<div class="relative" x-data="{ exportOpen: false }" x-show="slots.length > 0" x-cloak>
<button type="button" @click="exportOpen = !exportOpen" @click.away="exportOpen = false"
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-black text-sm uppercase tracking-widest text-emerald-500 hover:bg-emerald-500/10">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{{ __('Export') }}
<svg class="w-3 h-3 transition-transform duration-200" :class="exportOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div x-show="exportOpen" x-transition
class="absolute left-0 top-full mt-2 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl rounded-2xl p-2 z-30 min-w-[160px]"
x-cloak>
<a :href="`/admin/warehouses/machine-inventory/${selectedMachine.id}/export?export=csv`"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📄 {{ __('Export to CSV') }}
</a>
<a :href="`/admin/warehouses/machine-inventory/${selectedMachine.id}/export?export=excel`"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📊 {{ __('Export to Excel') }}
</a>
</div>
</div>
</div>
<div class="flex items-center gap-4">
@ -383,12 +296,6 @@
<span class="text-sm font-black tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<template x-if="slot.is_locked">
<div class="absolute top-4 right-5 flex items-center gap-1 px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 whitespace-nowrap select-none">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</div>
</template>
<div class="relative w-16 h-16 mb-4 mt-2">
<div
@ -457,16 +364,8 @@
</template>
</div>
<div>
<div class="flex items-center gap-2">
<span class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 text-[10px] font-black uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Locked (Sale Paused)') }}
</span>
</template>
</div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1 mt-1">
<template x-if="slot.product?.id">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">{{ __('ID') }}: <span x-text="slot.product.id"></span></span>
@ -536,12 +435,6 @@
</div>
<div class="shrink-0 text-right">
<p class="text-base font-black text-emerald-500" x-text="'$' + Math.floor(slot.product?.price || 0)"></p>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 mt-1 text-[10px] font-black px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</span>
</template>
</div>
</div>
@ -890,7 +783,6 @@
</div>
</div>
</div>
</div>
@endsection
@ -905,35 +797,6 @@ document.addEventListener('alpine:init', () => {
loading: false,
hasReplenishPermission: {{ auth()->user()->can('menu.warehouses.replenishments') ? 'true' : 'false' }},
// 機台庫存批次匯出
exportModalOpen: false,
exportSelectedIds: [],
get exportAllChecked() {
const total = {{ $exportMachines->count() }};
return total > 0 && this.exportSelectedIds.length === total;
},
openExportModal() {
this.exportSelectedIds = @js($exportMachines->pluck('id'));
this.exportModalOpen = true;
},
toggleExportAll(e) {
this.exportSelectedIds = e.target.checked ? @js($exportMachines->pluck('id')) : [];
},
doExport(format) {
if (this.exportSelectedIds.length === 0) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Please select at least one machine') }}', type: 'error' } }));
return;
}
const url = `/admin/warehouses/machine-inventory/export?export=${format}&ids=${this.exportSelectedIds.join(',')}`;
const a = document.createElement('a');
a.href = url;
a.setAttribute('download', '');
document.body.appendChild(a);
a.click();
a.remove();
this.exportModalOpen = false;
},
async init() {
const urlParams = new URLSearchParams(window.location.search);
const machineId = urlParams.get('machine_id');

View File

@ -104,7 +104,6 @@
'logs' => __('Machine Logs'),
'permissions' => __('Machine Permissions'),
'utilization' => __('Utilization Rate'),
'pricing' => __('Machine Pricing'),
'expiry' => __('Expiry Management'),
'maintenance' => __('Maintenance Records'),
'ui-elements' => __('UI Elements'),
@ -121,7 +120,6 @@
'promotions' => __('Promotions'),
'pass-codes' => __('Pass Codes'),
'store-gifts' => __('Store Gifts'),
'pharmacy-pickup' => __('menu.sales.pharmacy-pickup'),
'change-stock' => __('Change Stock'),
'machine-reports' => __('Machine Reports'),
'product-reports' => __('Product Reports'),

View File

@ -15,12 +15,8 @@
</head>
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
@php
// 取貨碼可能綁「商品」(product_idslot_no 為 null貨道刷碼當下挑) 或綁「貨道」(舊版)。
// 優先用直接綁定的商品;否則回退用 slot_no 對應貨道的商品。
$slot = $pickupCode->slot_no
? $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first()
: null;
$product = $pickupCode->product ?? $slot?->product;
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
$product = $slot?->product;
@endphp
<div class="w-full max-w-sm animate-luxury-in">
@ -57,7 +53,7 @@
<div class="flex items-center justify-between">
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Slot') }}</span>
<span class="text-2xl font-black text-slate-800 dark:text-white font-display">{{ $pickupCode->slot_no ?: '—' }}</span>
<span class="text-2xl font-black text-slate-800 dark:text-white font-display">{{ $pickupCode->slot_no }}</span>
</div>
<div class="w-px h-8 bg-slate-100 dark:bg-slate-800"></div>
<div class="text-center flex-1">

View File

@ -68,10 +68,6 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::get('/utilization-ajax/{id?}', [App\Http\Controllers\Admin\MachineController::class, 'utilizationData'])->name('utilization-ajax');
Route::get('/{machine}/slots-ajax', [App\Http\Controllers\Admin\MachineController::class, 'slotsAjax'])->name('slots-ajax');
Route::post('/{machine}/slots/expiry', [App\Http\Controllers\Admin\MachineController::class, 'updateSlotExpiry'])->name('slots.expiry.update');
Route::post('/{machine}/slots/lock', [App\Http\Controllers\Admin\MachineController::class, 'toggleSlotLock'])->name('slots.lock.toggle');
// 機台專屬定價(獨立頁,列全公司目錄,可上貨前先定價)
Route::get('/{machine}/pricing', [App\Http\Controllers\Admin\MachineController::class, 'pricing'])->name('pricing');
Route::post('/{machine}/pricing', [App\Http\Controllers\Admin\MachineController::class, 'updatePricing'])->name('pricing.update');
Route::get('/{machine}/logs-ajax', [App\Http\Controllers\Admin\MachineController::class, 'logsAjax'])->name('logs-ajax');
Route::get('/{machine}/temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'temperatureAjax'])->name('temperature-ajax');
Route::get('/{machine}/ambient-temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'ambientTemperatureAjax'])->name('ambient-temperature-ajax');
@ -123,9 +119,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 模組 4機台庫存總覽 (Force Route Refresh)
Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory');
Route::get('/machine-inventory/export', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventoryExportBatch'])->name('machine-inventory.export-batch');
Route::get('/machine-inventory/{machine}/slots', [App\Http\Controllers\Admin\WarehouseController::class, 'machineSlots'])->name('machine-inventory.slots');
Route::get('/machine-inventory/{machine}/export', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventoryExport'])->name('machine-inventory.export');
Route::get('/machine-inventory/{machine}/movements', [App\Http\Controllers\Admin\WarehouseController::class, 'machineStockMovements'])->name('machine-inventory.movements');
// 模組 5機台補貨
@ -148,10 +142,6 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::prefix('sales')->name('sales.')->group(function () {
Route::get('/', [App\Http\Controllers\Admin\SalesController::class, 'index'])->name('index');
// 手動補單(機台斷線/漏報時人工補登銷售紀錄)— 限系統管理員Controller 內把關)
Route::get('/manual/machine-slots', [App\Http\Controllers\Admin\SalesController::class, 'manualMachineSlots'])->name('manual.machine-slots');
Route::post('/manual', [App\Http\Controllers\Admin\SalesController::class, 'storeManualOrder'])->name('manual.store');
// 電子發票對帳/補開/作廢/列印
Route::post('/invoices/{invoice}/print', [App\Http\Controllers\Admin\SalesController::class, 'printInvoice'])->name('invoices.print');
Route::post('/invoices/{invoice}/reconcile', [App\Http\Controllers\Admin\SalesController::class, 'reconcileInvoice'])->name('invoices.reconcile');
@ -160,24 +150,19 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 管理者備註(訂單/發票各自獨立)
Route::patch('/orders/{order}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateOrderRemark'])->name('orders.remark');
Route::post('/orders/{order}/pickup-code', [App\Http\Controllers\Admin\SalesController::class, 'generateOrderPickupCode'])->name('orders.pickup-code');
Route::patch('/invoices/{invoice}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateInvoiceRemark'])->name('invoices.remark');
// 取貨碼
Route::get('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'pickupCodes'])->name('pickup-codes');
// 產碼用:回傳機台所屬公司的商品清單 + 機台目前已上架的 product_id供前端標示「機台未上架」提示
Route::get('/pickup-codes/company-products/{machine}', [App\Http\Controllers\Admin\SalesController::class, 'pickupCompanyProducts'])->name('pickup-codes.company-products');
Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store');
Route::patch('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'updatePickupCode'])->name('pickup-codes.update');
Route::delete('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'destroyPickupCode'])->name('pickup-codes.destroy');
Route::get('/pickup-codes/batch/{batchNo}/download', [App\Http\Controllers\Admin\SalesController::class, 'downloadPickupCodeBatch'])->name('pickup-codes.batch-download');
// 通行碼
Route::get('/pass-codes', [App\Http\Controllers\Admin\SalesController::class, 'passCodes'])->name('pass-codes');
Route::post('/pass-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePassCode'])->name('pass-codes.store');
Route::patch('/pass-codes/{passCode}', [App\Http\Controllers\Admin\SalesController::class, 'updatePassCode'])->name('pass-codes.update');
Route::delete('/pass-codes/{passCode}', [App\Http\Controllers\Admin\SalesController::class, 'destroyPassCode'])->name('pass-codes.destroy');
Route::get('/pass-codes/batch/{batchNo}/download', [App\Http\Controllers\Admin\SalesController::class, 'downloadPassCodeBatch'])->name('pass-codes.batch-download');
Route::get('/orders', [App\Http\Controllers\Admin\SalesController::class, 'orders'])->name('orders');
Route::get('/promotions', [App\Http\Controllers\Admin\SalesController::class, 'promotions'])->name('promotions');
@ -189,7 +174,6 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 領藥單(取物單模式 / 領藥模組)— 受權限 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/{machine}/products-ajax', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'productsAjax'])->name('pharmacy-pickup.products-ajax')->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');
@ -216,7 +200,6 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::prefix('data-config')->name('data-config.')->group(function () {
Route::middleware('can:menu.data-config.products')->group(function () {
Route::get('/products/template', [App\Http\Controllers\Admin\ProductController::class, 'downloadTemplate'])->name('products.template');
Route::get('/products/export', [App\Http\Controllers\Admin\ProductController::class, 'export'])->name('products.export');
Route::post('/products/import', [App\Http\Controllers\Admin\ProductController::class, 'import'])->name('products.import');
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');

View File

@ -1,157 +0,0 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 帳號樹狀層級隔離(第二+三批)行為測試:
* - 主帳號唯一性(第一個帳號自動成為主帳號)
* - parent_id / level 歸屬
* - 子帳號深度上限level 2 不可再建)
* - visibleTo 可見範圍隔離(只看自己直建的)
* - canManageAccount 越權防護(不能管旁線)
*/
class AccountHierarchyTest extends TestCase
{
use RefreshDatabase;
protected User $sysAdmin;
protected Company $company;
protected Role $tenantRole;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts'] as $p) {
Permission::create(['name' => $p, 'guard_name' => 'web']);
}
$superAdmin = Role::create(['name' => 'super-admin', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web']);
$superAdmin->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
Role::create(['name' => '客戶管理員角色模板', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web'])
->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
$this->sysAdmin = User::factory()->create(['company_id' => null]);
$this->sysAdmin->assignRole($superAdmin);
// 供租戶帳號使用、帶有子帳號管理權限的公司層級角色
$this->tenantRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$this->tenantRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
}
/** 建立一個租戶帳號(直接寫入層級欄位)並賦予可建子帳號的角色 */
private function makeTenantUser(array $attrs): User
{
$user = User::factory()->create(array_merge(['company_id' => $this->company->id], $attrs));
$user->assignRole($this->tenantRole);
return $user;
}
public function test_first_company_account_becomes_main(): void
{
$this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [
'name' => 'First', 'username' => 'first', 'email' => 'first@test.co', 'password' => 'password123',
'role' => '客戶管理員角色模板', 'status' => 1, 'company_id' => $this->company->id,
])->assertSessionHas('success');
$u = User::where('username', 'first')->first();
$this->assertTrue($u->is_admin);
$this->assertSame(0, $u->level);
$this->assertNull($u->parent_id);
}
public function test_second_company_account_is_not_main_and_is_child(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [
'name' => 'Second', 'username' => 'second', 'email' => 'second@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1, 'company_id' => $this->company->id,
])->assertSessionHas('success');
$u = User::where('username', 'second')->first();
$this->assertFalse($u->is_admin);
$this->assertSame(1, $u->level);
$this->assertSame($main->id, $u->parent_id);
}
public function test_tenant_admin_creates_sub_account_sets_parent_and_level(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->actingAs($main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'Sub', 'username' => 'sub', 'email' => 'sub@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('success');
$u = User::where('username', 'sub')->first();
$this->assertFalse($u->is_admin);
$this->assertSame(1, $u->level);
$this->assertSame($main->id, $u->parent_id);
$this->assertSame($this->company->id, $u->company_id);
}
public function test_level_two_sub_account_cannot_create_further(): void
{
$deep = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => null]);
$this->actingAs($deep)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'TooDeep', 'username' => 'toodeep', 'email' => 'toodeep@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('error');
$this->assertDatabaseMissing('users', ['username' => 'toodeep']);
}
public function test_visible_to_isolates_siblings_and_self(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$c = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => $a->id]);
// 子帳號 A 只看得到自己直建的 C看不到自己、旁線 B、主帳號
$visibleToA = User::visibleTo($a)->pluck('id')->all();
$this->assertSame([$c->id], $visibleToA);
// 主帳號看得到同公司全部
$visibleToMain = User::visibleTo($main)->pluck('id')->sort()->values()->all();
$this->assertEquals(collect([$main->id, $a->id, $b->id, $c->id])->sort()->values()->all(), $visibleToMain);
}
public function test_sub_account_cannot_manage_sibling(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
// A 嘗試刪除旁線 B → 被擋
$this->actingAs($a)->delete(route('admin.data-config.sub-accounts.destroy', $b->id))
->assertSessionHas('error');
$this->assertDatabaseHas('users', ['id' => $b->id, 'deleted_at' => null]);
// A 嘗試停用旁線 B → 被擋
$this->actingAs($a)->patch(route('admin.data-config.sub-accounts.status.toggle', $b->id))
->assertSessionHas('error');
}
public function test_main_account_can_manage_company_sub_account(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
// 主帳號可停用同公司子帳號
$this->actingAs($main)->patch(route('admin.data-config.sub-accounts.status.toggle', $a->id))
->assertSessionHas('success');
}
}

View File

@ -1,160 +0,0 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 第四批:角色指派子集 機台授權子集 把關測試。
* - 操作者不可指派「含自身未持有權限」的角色(防權限提升)。
* - 操作者不可授權「自己未被授權」的機台給他人(防機台層級提升)。
* - 操作者不可對非管轄帳號操作機台授權(越權)。
*/
class AccountSubsetGuardTest extends TestCase
{
use RefreshDatabase;
protected Company $company;
protected User $main;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm'] as $p) {
Permission::create(['name' => $p, 'guard_name' => 'web']);
}
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
// 主帳號角色:僅持有兩個權限(不含 extra.high.perm
$mainRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$mainRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
$this->main = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => true, 'level' => 0, 'parent_id' => null,
]);
$this->main->assignRole($mainRole);
}
// ── 角色指派子集 ─────────────────────────────────────────────
public function test_operator_cannot_assign_role_exceeding_own_permissions(): void
{
// 高權限角色:多出操作者沒有的 extra.high.perm
$highRole = Role::create(['name' => 'high-role', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$highRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm']);
$this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'X', 'username' => 'escalated', 'email' => 'e@test.co', 'password' => 'password123',
'role' => 'high-role', 'status' => 1,
])->assertSessionHas('error');
$this->assertDatabaseMissing('users', ['username' => 'escalated']);
}
public function test_operator_can_assign_role_within_own_permissions(): void
{
$this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'X', 'username' => 'ok_sub', 'email' => 'ok@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('success');
$this->assertDatabaseHas('users', ['username' => 'ok_sub']);
}
// ── 機台授權子集 ─────────────────────────────────────────────
public function test_operator_cannot_grant_machine_they_are_not_assigned(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id); // 操作者只被授權 m1
// 嘗試把自己沒有的 m2 授權給 target → 422
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [$m1->id, $m2->id],
])->assertStatus(422);
// 只授權自己有的 m1 → 成功
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [$m1->id],
])->assertStatus(200);
$this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]);
$this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]);
}
public function test_operator_cannot_assign_machines_to_non_managed_account(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
// 旁線帳號(非 operator 的下層)
$sibling = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id);
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $sibling->id), [
'machine_ids' => [$m1->id],
])->assertStatus(403);
}
public function test_sync_preserves_authorizations_outside_operator_subset(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id); // 操作者只可授權 m1
$target->machines()->attach([$m1->id, $m2->id]); // 目標既有 m1、m2m2 在操作者範圍外)
// 操作者送出清空([]):應只移除自己範圍內的 m1保留範圍外的 m2
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [],
])->assertStatus(200);
$this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]);
$this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]);
}
public function test_get_account_machines_only_lists_operator_subset(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id);
$response = $this->actingAs($operator)->getJson(route('admin.machines.permissions.accounts.get', $target->id));
$response->assertStatus(200);
$ids = collect($response->json('machines'))->pluck('id')->all();
$this->assertContains($m1->id, $ids);
$this->assertNotContains($m2->id, $ids); // 操作者沒被授權的機台不應出現在可授權清單
}
}

View File

@ -1,117 +0,0 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 第五批:角色層級隔離測試。
* - 子帳號只看得到 / 只能管理 自己建立的角色(看不到上層/旁線建的)。
* - 主帳號看得到同公司全部角色。
* - storeRole 寫入 created_byupdate/destroy canBeManagedBy 守衛。
*/
class RoleHierarchyTest extends TestCase
{
use RefreshDatabase;
protected Company $company;
protected User $main;
protected User $subA;
protected User $subB;
protected function setUp(): void
{
parent::setUp();
Permission::create(['name' => 'menu.data-config.sub-accounts', 'guard_name' => 'web']);
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
$role = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$role->syncPermissions(['menu.data-config.sub-accounts']);
$this->main = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->main->assignRole($role);
$this->subA = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$this->subA->assignRole($role);
$this->subB = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$this->subB->assignRole($role);
}
private function makeRole(string $name, User $creator): Role
{
return Role::create([
'name' => $name, 'company_id' => $this->company->id, 'is_system' => false,
'guard_name' => 'web', 'created_by' => $creator->id,
]);
}
public function test_role_visible_to_isolates_by_creator(): void
{
$roleMain = $this->makeRole('role-main', $this->main);
$roleA = $this->makeRole('role-a', $this->subA);
$roleB = $this->makeRole('role-b', $this->subB);
// 子帳號 A 只看得到自己建立的角色
$this->assertSame([$roleA->id], Role::visibleTo($this->subA)->pluck('id')->all());
// 主帳號看得到同公司全部角色(含 tenant-mgr 與三個新建的)
$mainVisible = Role::visibleTo($this->main)->pluck('id')->all();
foreach ([$roleMain->id, $roleA->id, $roleB->id] as $id) {
$this->assertContains($id, $mainVisible);
}
}
public function test_can_be_managed_by_creator_only_for_sub_account(): void
{
$roleMain = $this->makeRole('role-main', $this->main);
$roleA = $this->makeRole('role-a', $this->subA);
$this->assertTrue($roleA->canBeManagedBy($this->subA)); // 自己建的
$this->assertFalse($roleMain->canBeManagedBy($this->subA)); // 上層建的
$this->assertTrue($roleMain->canBeManagedBy($this->main)); // 主帳號管全公司
$this->assertTrue($roleA->canBeManagedBy($this->main)); // 主帳號管全公司
}
public function test_store_role_sets_created_by(): void
{
$this->actingAs($this->subA)->post(route('admin.data-config.sub-account-roles.store'), [
'name' => 'a-created-role',
'permissions' => ['menu.data-config.sub-accounts'],
])->assertSessionHas('success');
$role = Role::where('name', 'a-created-role')->where('company_id', $this->company->id)->first();
$this->assertNotNull($role);
$this->assertSame($this->subA->id, $role->created_by);
}
public function test_sub_account_cannot_update_others_role(): void
{
$roleB = $this->makeRole('role-b', $this->subB);
// A 嘗試改 B 建的角色 → 被擋
$this->actingAs($this->subA)->put(route('admin.data-config.sub-account-roles.update', $roleB->id), [
'name' => 'hijacked',
'permissions' => [],
])->assertSessionHas('error');
$this->assertDatabaseHas('roles', ['id' => $roleB->id, 'name' => 'role-b']);
}
public function test_sub_account_cannot_delete_others_role(): void
{
$roleMain = $this->makeRole('role-main', $this->main);
$this->actingAs($this->subA)->delete(route('admin.data-config.sub-account-roles.destroy', $roleMain->id))
->assertSessionHas('error');
$this->assertDatabaseHas('roles', ['id' => $roleMain->id]);
}
}

View File

@ -1,100 +0,0 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 銷售紀錄機台權限隔離:使用者只能看到「自己可存取機台」的交易訂單,
* 無該機台權限者不得看到其交易(修正先前訂單清單未依機台授權過濾的漏洞)。
*/
class SalesMachineAccessTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private User $main;
private Machine $mA;
private Machine $mB;
private int $orderA;
private int $orderB;
protected function setUp(): void
{
parent::setUp();
Permission::create(['name' => 'menu.sales', 'guard_name' => 'web']);
$this->company = Company::create(['name' => 'Co', 'code' => 'CO']);
$role = Role::create(['name' => 'r', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$role->syncPermissions(['menu.sales']);
$this->main = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => true, 'level' => 0]);
$this->main->assignRole($role);
$this->mA = Machine::factory()->create(['company_id' => $this->company->id]);
$this->mB = Machine::factory()->create(['company_id' => $this->company->id]);
$this->orderA = $this->makeOrder($this->mA->id);
$this->orderB = $this->makeOrder($this->mB->id);
}
private function makeOrder(int $machineId): int
{
return DB::table('orders')->insertGetId([
'company_id' => $this->company->id,
'machine_id' => $machineId,
'payment_type' => 1,
'total_amount' => 100,
'pay_amount' => 100,
'payment_status' => 1,
'status' => 'completed',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function visibleOrderIds(User $user): array
{
$response = $this->actingAs($user)->get(route('admin.sales.index'));
$response->assertOk();
return collect($response->viewData('orders')->items())->pluck('id')->all();
}
public function test_sub_account_with_machine_access_sees_only_that_machine_orders(): void
{
$sub = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$sub->assignRole(Role::where('company_id', $this->company->id)->first());
$sub->machines()->attach($this->mA->id); // 只被授權 mA
$ids = $this->visibleOrderIds($sub);
$this->assertContains($this->orderA, $ids);
$this->assertNotContains($this->orderB, $ids); // 未授權 mB → 看不到其訂單
}
public function test_sub_account_without_any_machine_access_sees_no_orders(): void
{
$sub = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$sub->assignRole(Role::where('company_id', $this->company->id)->first());
// 不授權任何機台
$ids = $this->visibleOrderIds($sub);
$this->assertNotContains($this->orderA, $ids);
$this->assertNotContains($this->orderB, $ids);
}
public function test_system_admin_sees_all_orders(): void
{
$sysAdmin = User::factory()->create(['company_id' => null]);
$ids = $this->visibleOrderIds($sysAdmin);
$this->assertContains($this->orderA, $ids);
$this->assertContains($this->orderB, $ids);
}
}

View File

@ -1,101 +0,0 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 售完(庫存歸 0)自動清空效期/批號 —— MySQLrecordDispense 內部寫
* machine_stock_movements type enum MySQL-only DDLsqlite :memory: 無法驗)。執行:
*
* ./vendor/bin/sail test -c phpunit.mysql.xml --filter ExpiryClearOnSelloutMysqlTest
*
* 在預設 sqlite 套件中自動 skip,不影響主套件綠燈。
*
* 動機:機台端「賣完清效期」已做,但 B009 補貨上報不回傳 expiry後台若殘留舊效期
* 日後管理員只補 stock 未改效期 update_inventory 把過期日下發 新批貨被誤鎖。
* 故後台扣庫存歸 0 時亦須清效期,與機台端對稱。
*/
class ExpiryClearOnSelloutMysqlTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
if (DB::connection()->getDriverName() !== 'mysql') {
$this->markTestSkipped('需 MySQL:machine_stock_movements.type enum 為 MySQL-only DDL。');
}
$this->company = Company::create(['name' => 'Test Company']);
$this->service = app(TransactionService::class);
}
private function dispense(string $serial, string $slotNo): array
{
return [
'serial_no' => $serial,
'slot_no' => $slotNo,
'product_id' => 101,
'amount' => 50,
'dispense_status' => 1,
];
}
/** 庫存賣到 0 → 效期與批號被清空。 */
public function test_clears_expiry_and_batch_when_stock_hits_zero(): void
{
$serial = '1234567890';
$machine = Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serial,
]);
$slot = $machine->slots()->create([
'slot_no' => '1',
'stock' => 1,
'max_stock' => 10,
'expiry_date' => '2030-12-31',
'batch_no' => 'B-001',
]);
$this->service->recordDispense($this->dispense($serial, '1'));
$fresh = $slot->fresh();
$this->assertEquals(0, $fresh->stock, '庫存應扣為 0');
$this->assertNull($fresh->expiry_date, '售完應清空效期');
$this->assertNull($fresh->batch_no, '售完應清空批號');
}
/** 庫存尚未歸 0 → 效期與批號保留(同批貨還在販售)。 */
public function test_keeps_expiry_when_stock_remains(): void
{
$serial = '1234567891';
$machine = Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serial,
]);
$slot = $machine->slots()->create([
'slot_no' => '2',
'stock' => 5,
'max_stock' => 10,
'expiry_date' => '2030-12-31',
'batch_no' => 'B-001',
]);
$this->service->recordDispense($this->dispense($serial, '2'));
$fresh = $slot->fresh();
$this->assertEquals(4, $fresh->stock, '庫存應扣為 4');
$this->assertEquals('2030-12-31', $fresh->expiry_date->format('Y-m-d'), '未售完應保留效期');
$this->assertEquals('B-001', $fresh->batch_no, '未售完應保留批號');
}
}

View File

@ -1,165 +0,0 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\Order;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 交易 finalize 的冪等性、跨機台防撞號與 RelateNumber 等價性測試。
*
* 對應改動:flow_id 入口以 serial 前綴命名空間化(無分隔符)+ 終態短路移入交易內加排他鎖。
* :sqlite :memory: 無真實併發,故此處驗的是「順序重送」的冪等(由終態短路保證);
* lockForUpdate 的真實併發效果需 MySQL 環境另測。
*/
class FinalizeIdempotencyTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
$this->company = Company::create(['name' => 'Test Company']);
$this->service = app(TransactionService::class);
}
private function makeMachine(string $serialNo, bool $taxInvoice = false): Machine
{
return Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serialNo,
'tax_invoice_enabled' => $taxInvoice,
]);
}
private function completedPayload(string $serialNo, string $rawFlowId, array $dispense = []): array
{
return [
'serial_no' => $serialNo,
'flow_id' => $rawFlowId,
'payment_type' => 1,
'order' => [
'flow_id' => $rawFlowId,
'total_amount' => 50,
'pay_amount' => 50,
'payment_type' => 1,
'status' => 'completed',
'payment_status' => 1,
],
'dispense' => $dispense,
];
}
/** 順序重送同一筆 → 只建一張單、出貨一次、庫存只扣一次。 */
public function test_resending_same_transaction_is_idempotent(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$this->makeMachine($serial);
// 不建貨道:recordDispense 在 slot=null 時仍建出貨紀錄,但跳過 decrement/stockMovement
// (stockMovement 的 type enum 是 MySQL-only DDL,sqlite :memory: 無法驗,屬既有環境限制)。
// 「出貨紀錄只一筆」與「庫存只扣一次」由同一道終態短路保證,此處以前者代證。
$dispense = [[
'slot_no' => '99',
'product_id' => 101,
'amount' => 50,
'dispense_status' => 1,
'remaining_stock' => 4,
]];
$first = $this->service->finalizeTransaction($this->completedPayload($serial, $raw, $dispense));
$second = $this->service->finalizeTransaction($this->completedPayload($serial, $raw, $dispense));
// 同一張單(終態短路)、出貨紀錄只有一筆(重送不重複扣庫存的同源保證)
$this->assertEquals($first->id, $second->id);
$this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
}
/** 不同機台、相同原始 flow_id → 命名空間化後為兩張獨立單,不互吞。 */
public function test_same_raw_flow_id_across_machines_creates_separate_orders(): void
{
$raw = '202606181530450001';
$machineA = $this->makeMachine('1111111111');
$machineB = $this->makeMachine('2222222222');
$orderA = $this->service->finalizeTransaction($this->completedPayload('1111111111', $raw));
$orderB = $this->service->finalizeTransaction($this->completedPayload('2222222222', $raw));
$this->assertNotEquals($orderA->id, $orderB->id);
$this->assertEquals('1111111111' . $raw, $orderA->flow_id);
$this->assertEquals('2222222222' . $raw, $orderB->flow_id);
}
/** display_flow_id 顯示用 accessor:剝除機台序號前綴,DB 仍存完整 serial+raw 唯一值。 */
public function test_display_flow_id_strips_serial_prefix(): void
{
$serial = '2501000062';
$raw = '202606190839470011';
$this->makeMachine($serial);
$order = $this->service->finalizeTransaction($this->completedPayload($serial, $raw));
// DB 值維持完整 28 碼;顯示值剝掉序號前綴 → 乾淨 18 碼
$this->assertEquals($serial . $raw, $order->flow_id);
$this->assertEquals($raw, $order->fresh()->display_flow_id);
}
/** RelateNumber = serial + rawFlowId(無分隔符、≤30、與線上推導逐字等價,不可雙重 serial)。 */
public function test_relate_number_equals_serial_plus_raw_flow_id(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$this->makeMachine($serial, taxInvoice: true);
$payload = $this->completedPayload($serial, $raw);
$payload['invoice'] = ['status' => 'pending', 'amount' => 50];
$order = $this->service->finalizeTransaction($payload);
$invoice = Invoice::where('flow_id', $serial . $raw)->first();
$this->assertNotNull($invoice);
$this->assertEquals($serial . $raw, $invoice->relate_number);
$this->assertLessThanOrEqual(30, strlen($invoice->relate_number));
// 不可出現雙重 serial 前綴
$this->assertStringStartsNotWith($serial . $serial, $invoice->relate_number);
}
/** pending 先送、completed 後送 → 同一張單狀態轉移(命名空間化前後一致)。 */
public function test_pending_then_completed_transitions_same_order(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$this->makeMachine($serial);
$pending = $this->completedPayload($serial, $raw);
$pending['order']['status'] = 'pending';
$pending['order']['payment_status'] = 0;
$pending['dispense'] = [];
$pendingOrder = $this->service->finalizeTransaction($pending);
$this->assertEquals('pending', $pendingOrder->status);
// slot_no=99 不存在 → 跳過 stockMovement(MySQL-only),仍建出貨紀錄
$completed = $this->completedPayload($serial, $raw, [[
'slot_no' => '99', 'product_id' => 101, 'amount' => 50,
'dispense_status' => 1, 'remaining_stock' => 4,
]]);
$completedOrder = $this->service->finalizeTransaction($completed);
// 同一張單(命名空間化前後 key 一致)、轉為終態、出貨紀錄只一筆
$this->assertEquals($pendingOrder->id, $completedOrder->id);
$this->assertEquals('completed', $completedOrder->fresh()->status);
$this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
}
}

View File

@ -1,94 +0,0 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\Order;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 真實庫存扣減的冪等驗證 —— MySQLmachine_stock_movements.type enum MySQL-only DDL
* sqlite :memory: 無法驗)。執行:
*
* ./vendor/bin/sail test -c phpunit.mysql.xml --filter FinalizeStockMysqlTest
*
* 在預設 sqlite 套件中自動 skip,不影響主套件綠燈。
*/
class FinalizeStockMysqlTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
if (DB::connection()->getDriverName() !== 'mysql') {
$this->markTestSkipped('需 MySQL:machine_stock_movements.type enum 為 MySQL-only DDL。');
}
$this->company = Company::create(['name' => 'Test Company']);
$this->service = app(TransactionService::class);
}
private function completedPayloadWithDispense(string $serialNo, string $rawFlowId, string $slotNo): array
{
return [
'serial_no' => $serialNo,
'flow_id' => $rawFlowId,
'payment_type' => 1,
'order' => [
'flow_id' => $rawFlowId,
'total_amount' => 50,
'pay_amount' => 50,
'payment_type' => 1,
'status' => 'completed',
'payment_status' => 1,
],
'dispense' => [[
'slot_no' => $slotNo,
'product_id' => 101,
'amount' => 50,
'dispense_status' => 1,
'remaining_stock' => 4,
]],
];
}
/** 出貨成功扣庫存一次;重送同一筆 → 庫存不再被扣、出貨/異動紀錄不重複(終態短路 + 命名空間化)。 */
public function test_dispense_decrements_stock_once_and_resend_is_idempotent(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$machine = Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serial,
]);
$slot = $machine->slots()->create(['slot_no' => '1', 'stock' => 5, 'max_stock' => 10]);
$payload = $this->completedPayloadWithDispense($serial, $raw, '1');
// 第一次:扣庫存一次
$first = $this->service->finalizeTransaction($payload);
$this->assertEquals(4, $slot->fresh()->stock);
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DB::table('machine_stock_movements')
->where('slot_no', '1')->where('machine_id', $machine->id)->count());
// 重送:庫存維持 4,紀錄不重複
$second = $this->service->finalizeTransaction($payload);
$this->assertEquals($first->id, $second->id);
$this->assertEquals(4, $slot->fresh()->stock, '重送不可再扣庫存');
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DB::table('machine_stock_movements')
->where('slot_no', '1')->where('machine_id', $machine->id)->count());
$this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count());
}
}