Compare commits
151 Commits
feat/trans
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777ae82833 | ||
|
|
2bdad21810 | ||
|
|
42ec18188e | ||
|
|
c4d34926a6 | ||
|
|
acb888c2d1 | ||
|
|
168a990a7e | ||
|
|
07684bbb90 | ||
|
|
3e2069e099 | ||
|
|
34b0a7faf6 | ||
|
|
4494dc60fb | ||
|
|
83e1beb7e2 | ||
|
|
09f39af923 | ||
|
|
650e2412da | ||
|
|
1d745c5722 | ||
|
|
c08fa55e05 | ||
|
|
cb8a805a21 | ||
|
|
d8f549d390 | ||
|
|
d1a82a40fa | ||
|
|
c2d289c92a | ||
|
|
09ebf911ed | ||
|
|
be73e7c7a0 | ||
|
|
dc9421e482 | ||
| 1602895d77 | |||
| 6ed11bf858 | |||
| e2281fa425 | |||
| ce9e52efd9 | |||
| d4d61461fe | |||
| d90621f8c7 | |||
| 330b81ab7b | |||
| 3785026749 | |||
| 179348ad66 | |||
| 9dbde50f56 | |||
| 6bb08a13ce | |||
|
|
65f8ce2d37 | ||
|
|
1ace7da140 | ||
|
|
2dd14fd1b4 | ||
|
|
1404d40f71 | ||
|
|
0c29cdae97 | ||
|
|
aa4244b16d | ||
|
|
ef1bdfe1d5 | ||
|
|
8a92e1c1ff | ||
|
|
7499c42878 | ||
|
|
55cf6b8331 | ||
|
|
5a4d681d13 | ||
|
|
1722a192a8 | ||
|
|
1aef703882 | ||
|
|
5e0bfeb544 | ||
|
|
be9c70d524 | ||
|
|
146dac4493 | ||
|
|
697c52eba3 | ||
|
|
201663741d | ||
|
|
b77165d7b3 | ||
|
|
41e9b0d57c | ||
|
|
ee22ff9e6d | ||
|
|
7ba6d9ebe6 | ||
|
|
16a8ee7986 | ||
|
|
06d68a2783 | ||
|
|
7739d6016f | ||
|
|
f571f99084 | ||
|
|
a5d9aace56 | ||
|
|
fb71ecddaf | ||
|
|
2736c382a2 | ||
|
|
de2f098309 | ||
|
|
7044d4cefd | ||
|
|
a65f4cd45c | ||
|
|
4e9d21d1d6 | ||
|
|
38f5071975 | ||
|
|
6e5307933f | ||
|
|
61825e706a | ||
|
|
1bd8971310 | ||
|
|
7f4e67129f | ||
|
|
6ce4c2c8ed | ||
|
|
3007bfb254 | ||
|
|
af78c48d8e | ||
|
|
aecb2f17d1 | ||
|
|
f8c6da3acc | ||
|
|
9a6036d184 | ||
|
|
1a1518c611 | ||
|
|
47073f0a96 | ||
|
|
bec02ac546 | ||
|
|
a2faace4bd | ||
|
|
fcff9a0fc6 | ||
|
|
3293a76c91 | ||
|
|
d1c7940db9 | ||
|
|
e0532a539f | ||
|
|
94a941e100 | ||
|
|
fcd0588686 | ||
|
|
c5a3f99113 | ||
|
|
f37f41c5c3 | ||
|
|
8d951a64e6 | ||
|
|
cfbe16c519 | ||
|
|
3c1d5b73ed | ||
|
|
5621cad1bf | ||
|
|
3a793978b4 | ||
|
|
1685b858f9 | ||
|
|
0da3887aa6 | ||
|
|
8e0a30eb98 | ||
|
|
db9769b209 | ||
|
|
62d3242db5 | ||
|
|
5a501eb199 | ||
|
|
9e9ed4bc9e | ||
|
|
ecfbc904a7 | ||
|
|
871b223440 | ||
|
|
9856a68d84 | ||
|
|
f5cb9a8f7d | ||
|
|
eb5b75cf78 | ||
|
|
6b430bdd88 | ||
|
|
a5291545a5 | ||
|
|
7b6dc3cb8e | ||
|
|
76409c5f21 | ||
|
|
f415e138da | ||
|
|
ca3d3df398 | ||
|
|
09d669cc7c | ||
|
|
99debbf259 | ||
|
|
2cecdba73a | ||
|
|
003ba9b1ad | ||
|
|
e3593a4ba8 | ||
|
|
d8495766e1 | ||
|
|
ae4556584f | ||
|
|
8a7cb4fcf7 | ||
|
|
eeb9a67577 | ||
|
|
95ac9cc10d | ||
|
|
e7acea8ffc | ||
|
|
1ec149a624 | ||
|
|
f8a56f2b4e | ||
|
|
80300dfc67 | ||
|
|
f01bffecd9 | ||
|
|
3dc5bd6ee1 | ||
|
|
159fa38fc0 | ||
|
|
338bd2914f | ||
|
|
04195caf9b | ||
|
|
83015b841b | ||
|
|
33d3eba998 | ||
|
|
db0099f8c9 | ||
|
|
372f2fc55d | ||
|
|
8256fcb623 | ||
|
|
c0f6cb5ab6 | ||
|
|
a32572facb | ||
|
|
b8135acca9 | ||
|
|
a44b981059 | ||
|
|
d84b2de1b7 | ||
|
|
7a97792b67 | ||
|
|
6fe2ef4349 | ||
|
|
e0c4da0933 | ||
|
|
a969d2c4cc | ||
|
|
4e5f952f28 | ||
|
|
a1695789dd | ||
|
|
cfa824f905 | ||
|
|
1a004a4d08 | ||
|
|
fa50d39020 | ||
|
|
4e0f4dd747 |
@ -95,6 +95,7 @@ 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'),
|
||||
|
||||
@ -75,8 +75,10 @@ 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) {
|
||||
@ -121,6 +123,7 @@ 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();
|
||||
|
||||
@ -364,7 +367,11 @@ class MachineSettingController extends AdminController
|
||||
'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
|
||||
$allowedShoppingModes = ['basic', 'employee_card', 'pickup_sheet'];
|
||||
$shoppingMode = $settings['shopping_mode'] ?? 'basic';
|
||||
if (!in_array($shoppingMode, $allowedShoppingModes, true)) {
|
||||
$shoppingMode = 'basic';
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
@ -405,6 +412,9 @@ 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;
|
||||
@ -428,12 +438,20 @@ 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) {
|
||||
$jsonSettings[$field] = $data[$field];
|
||||
}
|
||||
|
||||
// 領藥單模組:僅在「取物單(pickup_sheet)」模式下可啟用,其他模式一律關閉
|
||||
$jsonSettings['pharmacy_pickup_enabled'] = ($shoppingMode === 'pickup_sheet')
|
||||
? (bool) ($settings['pharmacy_pickup_enabled'] ?? false)
|
||||
: false;
|
||||
|
||||
// 顯示語系 (languages):機台螢幕可切換的語系,最多 N 種,僅系統管理員可設定。
|
||||
// 商品翻譯範圍由「公司機台語系聯集」反推,故此處異動需失效聯集快取並重建商品目錄。
|
||||
// 非系統管理員即使送出 languages 亦一律忽略(不報錯,讓其仍可儲存其他設定)。
|
||||
|
||||
@ -32,10 +32,9 @@ class MachinePermissionController extends AdminController
|
||||
}])
|
||||
->whereNotNull('company_id');
|
||||
|
||||
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
|
||||
if (!$currentUser->isSystemAdmin()) {
|
||||
$userQuery->where('company_id', $currentUser->company_id);
|
||||
} elseif ($company_id) {
|
||||
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
|
||||
$userQuery->visibleTo($currentUser);
|
||||
if ($currentUser->isSystemAdmin() && $company_id) {
|
||||
// 系統管理員的篩選邏輯
|
||||
$userQuery->where('company_id', $company_id);
|
||||
}
|
||||
@ -54,6 +53,31 @@ 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: 取得特定帳號的機台分配狀態
|
||||
*/
|
||||
@ -61,17 +85,18 @@ class MachinePermissionController extends AdminController
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
// 層級越權防護:只能操作可管轄範圍內的帳號。
|
||||
if (!$currentUser->canManageAccount($user)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
|
||||
// 可授權機台清單限縮為「操作者本身可授權」的機台子集,避免把自己沒有的機台授權出去。
|
||||
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
|
||||
$machines = Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $user->company_id)
|
||||
->whereIn('id', $assignableIds)
|
||||
->get(['id', 'name', 'serial_no']);
|
||||
|
||||
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
|
||||
|
||||
$assignedIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id')->toArray();
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
@ -87,8 +112,8 @@ class MachinePermissionController extends AdminController
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
// 層級越權防護:只能操作可管轄範圍內的帳號。
|
||||
if (!$currentUser->canManageAccount($user)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
@ -97,20 +122,27 @@ class MachinePermissionController extends AdminController
|
||||
'machine_ids.*' => 'exists:machines,id'
|
||||
]);
|
||||
|
||||
// 加固驗證:確保所有機台 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);
|
||||
}
|
||||
// 授權子集約束:被指派的機台必須是「操作者本身可授權機台」的子集,
|
||||
// 既確保同公司,也避免子帳號把自己沒被授權的機台授權給他人。
|
||||
$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);
|
||||
}
|
||||
|
||||
$user->machines()->sync($request->machine_ids ?? []);
|
||||
// 只在「操作者可授權子集」範圍內做增刪;範圍外的既有授權一律保留,避免覆蓋式 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());
|
||||
}
|
||||
|
||||
$message = __('Machine permissions updated successfully.');
|
||||
session()->flash('success', $message);
|
||||
|
||||
@ -2,10 +2,15 @@
|
||||
|
||||
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
|
||||
@ -203,9 +208,12 @@ 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()
|
||||
@ -257,6 +265,117 @@ 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)
|
||||
*/
|
||||
|
||||
@ -14,10 +14,8 @@ class PermissionController extends Controller
|
||||
$user = auth()->user();
|
||||
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
|
||||
// 租戶隔離:租戶只能看到自己公司的角色
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
// 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。
|
||||
$query->visibleTo($user);
|
||||
|
||||
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search)
|
||||
$search = request()->input('roles_search', request()->input('search'));
|
||||
@ -88,6 +86,11 @@ 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);
|
||||
@ -126,6 +129,7 @@ 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,
|
||||
]);
|
||||
|
||||
@ -182,6 +186,11 @@ 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 = [
|
||||
@ -223,10 +232,20 @@ 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.'));
|
||||
}
|
||||
@ -251,10 +270,8 @@ class PermissionController extends Controller
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
|
||||
// ── 帳號列表 ──────────────────────────────────────────────────
|
||||
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$usersQuery->where('company_id', $user->company_id);
|
||||
}
|
||||
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
|
||||
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
|
||||
if ($search = $request->input('accounts_search', $request->input('search'))) {
|
||||
$usersQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
@ -269,22 +286,24 @@ class PermissionController extends Controller
|
||||
$accounts_per_page = $request->input('accounts_per_page', 10);
|
||||
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
|
||||
|
||||
// Modal 用的角色選單(不分頁)
|
||||
$rolesForSelect = \App\Models\System\Role::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$rolesForSelect->forCompany($user->company_id);
|
||||
}
|
||||
// Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。
|
||||
// 預先載入 permissions 以避免下方子集過濾造成 N+1。
|
||||
$rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user);
|
||||
$roles = $rolesForSelect->get();
|
||||
// 子集過濾:租戶只能指派「權限為自身子集」的角色(與 store/update 的後端 guard 一致,避免下拉出現越權選項)。
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$operatorPerms = $user->getAllPermissions()->pluck('name');
|
||||
$roles = $roles->filter(
|
||||
fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty()
|
||||
)->values();
|
||||
}
|
||||
|
||||
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
|
||||
$paginated_roles = collect();
|
||||
$all_permissions = collect();
|
||||
|
||||
if ($isSubAccountRoute) {
|
||||
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$rolesQuery->where('company_id', $user->company_id);
|
||||
}
|
||||
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
|
||||
if ($search = $request->input('roles_search')) {
|
||||
$rolesQuery->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
@ -328,6 +347,12 @@ 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;
|
||||
|
||||
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
|
||||
@ -374,6 +399,7 @@ class PermissionController extends Controller
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company_id,
|
||||
'is_system' => false,
|
||||
'is_company_admin' => true,
|
||||
]);
|
||||
$newRole->syncPermissions($role->getPermissionNames());
|
||||
$role = $newRole;
|
||||
@ -383,6 +409,39 @@ 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'],
|
||||
@ -390,8 +449,10 @@ 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' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
|
||||
'is_admin' => $isMainAccount,
|
||||
]);
|
||||
|
||||
$user->assignRole($role);
|
||||
@ -410,6 +471,11 @@ 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,
|
||||
@ -500,7 +566,7 @@ class PermissionController extends Controller
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $target_company_id,
|
||||
'is_system' => false,
|
||||
'is_admin' => true,
|
||||
'is_company_admin' => true,
|
||||
]);
|
||||
$newRole->syncPermissions($roleObj->getPermissionNames());
|
||||
$roleObj = $newRole;
|
||||
@ -509,6 +575,14 @@ 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 角色
|
||||
@ -527,15 +601,31 @@ 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;
|
||||
@ -556,6 +646,11 @@ 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();
|
||||
|
||||
|
||||
154
app/Http/Controllers/Admin/PharmacyPickupController.php
Normal file
154
app/Http/Controllers/Admin/PharmacyPickupController.php
Normal file
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Services\Transaction\PharmacyPickupService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
/**
|
||||
* 領藥單管理(銷售管理 → 領藥單)。
|
||||
*
|
||||
* 授權雙閘:
|
||||
* 1. 選單/頁面:權限 menu.sales.pharmacy-pickup(角色/帳號權限可勾選)。
|
||||
* 2. 機台行為:機台系統設定 shopping_mode=pickup_sheet 且 pharmacy_pickup_enabled=true。
|
||||
*/
|
||||
class PharmacyPickupController extends AdminController
|
||||
{
|
||||
public function __construct(private PharmacyPickupService $service)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 僅列出「取物單模式 + 領藥單開關開啟」的機台(受 TenantScoped 自動租戶隔離)
|
||||
$machines = Machine::query()
|
||||
->where('settings->shopping_mode', 'pickup_sheet')
|
||||
->where('settings->pharmacy_pickup_enabled', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$selectedMachine = null;
|
||||
$products = new Collection();
|
||||
|
||||
if ($request->filled('machine_id')) {
|
||||
$selectedMachine = $machines->firstWhere('id', (int) $request->input('machine_id'));
|
||||
if ($selectedMachine) {
|
||||
$products = $this->service->availableProducts($selectedMachine);
|
||||
}
|
||||
}
|
||||
|
||||
$orders = Order::pharmacyPickup()
|
||||
->with(['machine:id,name,serial_no', 'items', 'creator:id,name', 'pickupCode'])
|
||||
->latest()
|
||||
->paginate($request->input('per_page', 10))
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.sales.pharmacy-pickup.index', [
|
||||
'title' => __('menu.sales.pharmacy-pickup'),
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
'products' => $products,
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'pricing_slip_no' => 'nullable|string|max:64',
|
||||
]);
|
||||
|
||||
// 由勾選的 products[] + qty[product_id] 組出 items
|
||||
$productIds = (array) $request->input('products', []);
|
||||
$qtyMap = (array) $request->input('qty', []);
|
||||
$items = [];
|
||||
foreach ($productIds as $pid) {
|
||||
$q = (int) ($qtyMap[$pid] ?? 0);
|
||||
if ($q > 0) {
|
||||
$items[] = ['product_id' => (int) $pid, 'quantity' => $q];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$order = $this->service->create([
|
||||
'machine_id' => (int) $request->input('machine_id'),
|
||||
'pricing_slip_no' => $request->input('pricing_slip_no'),
|
||||
'items' => $items,
|
||||
], Auth::id());
|
||||
} catch (ValidationException $e) {
|
||||
return back()->withErrors($e->errors())->withInput();
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.sales.pharmacy-pickup', ['machine_id' => $request->input('machine_id')])
|
||||
->with('success', __('Pharmacy pickup order created: :no', ['no' => $order->order_no]))
|
||||
->with('created_order_id', $order->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可列印的領藥單(QR 內嵌 SVG,內容為 8 碼領藥碼,機台掃描後送 B660 驗證)。
|
||||
*/
|
||||
public function print(Order $order)
|
||||
{
|
||||
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
|
||||
|
||||
$order->load(['items', 'machine', 'pickupCode', 'creator:id,name']);
|
||||
$code = $order->pickupCode?->code;
|
||||
$qrSvg = $code
|
||||
? QrCode::format('svg')->size(220)->margin(1)->errorCorrection('M')->generate($code)
|
||||
: null;
|
||||
|
||||
return view('admin.sales.pharmacy-pickup.print', [
|
||||
'order' => $order,
|
||||
'qrSvg' => $qrSvg,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Order $order)
|
||||
{
|
||||
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
|
||||
|
||||
if (in_array($order->status, [Order::STATUS_COMPLETED, Order::STATUS_FAILED], true)
|
||||
|| $order->delivery_status === Order::DELIVERY_STATUS_SUCCESS) {
|
||||
return back()->with('error', __('This pharmacy pickup order can no longer be cancelled.'));
|
||||
}
|
||||
|
||||
$order->update(['status' => Order::STATUS_FAILED]);
|
||||
if ($order->pickupCode) {
|
||||
$order->pickupCode->update(['status' => 'cancelled']);
|
||||
}
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
'user_id' => Auth::id(),
|
||||
'module' => 'pharmacy_pickup',
|
||||
'action' => 'cancel',
|
||||
'target_id' => $order->id,
|
||||
'target_type' => Order::class,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Pharmacy pickup order cancelled.'));
|
||||
}
|
||||
}
|
||||
@ -584,6 +584,136 @@ 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 BOM,Excel 開啟不亂碼
|
||||
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();
|
||||
|
||||
@ -142,7 +142,7 @@ class RemoteController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
|
||||
'command_type' => 'required|string|in:reboot,reboot_force,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
|
||||
'amount' => 'nullable|integer|min:0',
|
||||
'slot_no' => 'nullable|string',
|
||||
'note' => 'nullable|string|max:255',
|
||||
|
||||
@ -19,6 +19,7 @@ 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;
|
||||
|
||||
@ -34,7 +35,10 @@ class SalesController extends Controller
|
||||
$search = $request->input('search');
|
||||
$machineId = $request->input('machine_id');
|
||||
$paymentType = $request->input('payment_type');
|
||||
$status = $request->input('status');
|
||||
// 付款狀態預設只看「已完成」:避免客戶把 failed(付款失敗/逾時) 誤會成刷卡失敗。
|
||||
// 「所有付款狀態」為明確選項 status=all(非空值,AJAX 才不會當空值丟掉)。
|
||||
$status = $request->input('status', 'completed');
|
||||
$deliveryStatus = $request->input('delivery_status');
|
||||
$startDate = $request->input('start_date');
|
||||
$endDate = $request->input('end_date');
|
||||
|
||||
@ -55,6 +59,7 @@ 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'),
|
||||
],
|
||||
@ -83,6 +88,16 @@ 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);
|
||||
@ -125,7 +140,12 @@ class SalesController extends Controller
|
||||
// 訂單專用過濾器
|
||||
if ($tab === 'orders') {
|
||||
if ($paymentType) $ordersQuery->where('payment_type', $paymentType);
|
||||
if ($status) $ordersQuery->where('status', $status);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 發票專用過濾器:狀態(pending/issued/failed/void)
|
||||
@ -146,6 +166,11 @@ 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,
|
||||
@ -157,6 +182,98 @@ 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)
|
||||
*/
|
||||
@ -180,6 +297,170 @@ 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)
|
||||
{
|
||||
@ -192,9 +473,17 @@ class SalesController extends Controller
|
||||
'tab' => $tab,
|
||||
];
|
||||
|
||||
// 批次產生數量上限/快捷選項依角色分級:平台管理員 1000(10/50/100/1000)、客戶端 20(10/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') {
|
||||
$query = PickupCode::with(['machine.slots.product', 'creator', 'order'])->latest();
|
||||
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準,whereHas 會套用 Machine 的 machine_access 全域 scope)
|
||||
$query = PickupCode::with(['machine.slots.product', 'product', 'creator', 'order'])
|
||||
->whereHas('machine')
|
||||
->latest();
|
||||
|
||||
if ($request->search) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
@ -219,7 +508,12 @@ class SalesController extends Controller
|
||||
|
||||
// 2. 操作紀錄 (logs)
|
||||
if (!$isAjax || $tab === 'logs') {
|
||||
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order']);
|
||||
// 同上:操作紀錄只顯示可存取機台的資料(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');
|
||||
});
|
||||
|
||||
if ($request->filled('search_log')) {
|
||||
$search = $request->input('search_log');
|
||||
@ -285,25 +579,105 @@ 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',
|
||||
'slot_no' => 'required|string',
|
||||
// 取貨碼改綁「商品」:新流程傳 product_id;舊的綁「貨道」流程仍可傳 slot_no(向後相容)。
|
||||
'product_id' => 'required_without:slot_no|nullable|exists:products,id',
|
||||
'slot_no' => 'required_without:product_id|nullable|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'],
|
||||
'slot_no' => $validated['slot_no'],
|
||||
'product_id' => $validated['product_id'] ?? null,
|
||||
'slot_no' => $validated['slot_no'] ?? null,
|
||||
'code' => $request->custom_code ?? PickupCode::generateUniqueCode($validated['machine_id']),
|
||||
'usage_limit' => $validated['usage_limit'] ?? 1,
|
||||
'usage_count' => 0,
|
||||
@ -326,6 +700,30 @@ 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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新取貨碼 (僅限修改時間)
|
||||
*/
|
||||
@ -362,6 +760,11 @@ 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']);
|
||||
|
||||
@ -409,9 +812,17 @@ class SalesController extends Controller
|
||||
'tab' => $tab,
|
||||
];
|
||||
|
||||
// 批次產生數量上限/快捷選項依角色分級:平台管理員 1000(10/50/100/1000)、客戶端 20(10/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') {
|
||||
$query = PassCode::with(['machine', 'creator'])->latest();
|
||||
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準,whereHas 會套用 Machine 的 machine_access 全域 scope)
|
||||
$query = PassCode::with(['machine', 'creator'])
|
||||
->whereHas('machine')
|
||||
->latest();
|
||||
|
||||
if ($request->search) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
@ -447,7 +858,9 @@ class SalesController extends Controller
|
||||
|
||||
// 2. 操作紀錄 (logs)
|
||||
if (!$isAjax || $tab === 'logs') {
|
||||
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode']);
|
||||
// 同上:操作紀錄只顯示可存取機台的資料(pass_code_logs.machine_id 為 NOT NULL)
|
||||
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode'])
|
||||
->whereHas('machine');
|
||||
|
||||
if ($request->filled('search_log')) {
|
||||
$search = $request->input('search_log');
|
||||
@ -513,22 +926,87 @@ 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' => 'required|string|min:4|max:12',
|
||||
'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);
|
||||
$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' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']),
|
||||
'code' => $request->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(),
|
||||
]);
|
||||
@ -546,6 +1024,125 @@ 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 BOM,Excel 開啟才不會亂碼
|
||||
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',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通行碼
|
||||
*/
|
||||
@ -587,6 +1184,11 @@ 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']);
|
||||
|
||||
@ -618,7 +1220,10 @@ class SalesController extends Controller
|
||||
|
||||
// 1. 來店禮列表 (list)
|
||||
if (!$isAjax || $tab === 'list') {
|
||||
$query = WelcomeGift::with(['machine', 'creator'])->latest();
|
||||
// 僅顯示目前帳號可存取機台的來店禮(機台授權以 machine_user 為準,whereHas 會套用 Machine 的 machine_access 全域 scope)
|
||||
$query = WelcomeGift::with(['machine', 'creator'])
|
||||
->whereHas('machine')
|
||||
->latest();
|
||||
|
||||
if ($request->search) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
@ -654,7 +1259,9 @@ class SalesController extends Controller
|
||||
|
||||
// 2. 操作紀錄 (logs)
|
||||
if (!$isAjax || $tab === 'logs') {
|
||||
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no']);
|
||||
// 同上:操作紀錄只顯示可存取機台的資料(welcome_gift_logs.machine_id 為 NOT NULL)
|
||||
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no'])
|
||||
->whereHas('machine');
|
||||
|
||||
if ($request->filled('search_log')) {
|
||||
$search = $request->input('search_log');
|
||||
@ -846,6 +1453,11 @@ 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']);
|
||||
|
||||
@ -935,6 +1547,7 @@ class SalesController extends Controller
|
||||
'pending' => __('Pending'),
|
||||
'paid' => __('Paid'),
|
||||
'completed' => __('Completed'),
|
||||
'awaiting_pickup' => __('Awaiting Pickup'),
|
||||
'failed' => __('Failed'),
|
||||
'abandoned' => __('Unpaid'),
|
||||
'cancelled' => __('Cancelled'),
|
||||
@ -1030,7 +1643,7 @@ class SalesController extends Controller
|
||||
$row = [
|
||||
$inv->invoice_no ?: '---',
|
||||
$inv->invoice_date ?: '---',
|
||||
$inv->flow_id ?: '---',
|
||||
$inv->display_flow_id ?: '---',
|
||||
$inv->machine->name ?? ($inv->order->machine->name ?? 'Unknown'),
|
||||
$inv->machine->serial_no ?? ($inv->order->machine->serial_no ?? '---'),
|
||||
$inv->order->order_no ?? '---',
|
||||
|
||||
@ -110,6 +110,15 @@ 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()) {
|
||||
|
||||
@ -722,10 +722,206 @@ class WarehouseController extends Controller
|
||||
? \App\Models\System\Company::active()->orderBy('name')->get()
|
||||
: collect();
|
||||
|
||||
return view('admin.warehouses.machine-inventory', compact('machines', 'companies'));
|
||||
// 供批次匯出 modal 勾選用的完整機台清單(全域 scope 已限可存取機台)
|
||||
$exportMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
|
||||
|
||||
return view('admin.warehouses.machine-inventory', compact('machines', 'companies', 'exportMachines'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 匯出單一機台的「每個貨道」明細(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:取得單台機台貨道詳情
|
||||
*/
|
||||
@ -840,10 +1036,9 @@ 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');
|
||||
if (!$isSystemAdmin) {
|
||||
$userQuery->where('company_id', $currentUser->company_id);
|
||||
} elseif ($companyId !== '') {
|
||||
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
|
||||
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser);
|
||||
if ($isSystemAdmin && $companyId !== '') {
|
||||
$userQuery->where('company_id', $companyId);
|
||||
}
|
||||
$users = $userQuery->get(['id', 'name']);
|
||||
@ -877,7 +1072,10 @@ class WarehouseController extends Controller
|
||||
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
// 補貨針對機台,單據歸屬以「機台所綁公司」為準(系統管理員可為他公司機台建單)
|
||||
$machine = Machine::findOrFail($validated['machine_id']);
|
||||
|
||||
DB::transaction(function () use ($validated, $machine) {
|
||||
$prefix = 'RP-' . now()->format('Ymd') . '-';
|
||||
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
|
||||
->withTrashed()
|
||||
@ -889,10 +1087,10 @@ class WarehouseController extends Controller
|
||||
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$order = ReplenishmentOrder::create([
|
||||
'company_id' => Auth::user()->company_id,
|
||||
'company_id' => $machine->company_id,
|
||||
'order_no' => $order_no,
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'machine_id' => $machine->id,
|
||||
'status' => ReplenishmentOrder::STATUS_PENDING,
|
||||
'note' => $validated['note'] ?? null,
|
||||
'created_by' => Auth::id(),
|
||||
@ -1011,7 +1209,7 @@ class WarehouseController extends Controller
|
||||
}
|
||||
|
||||
// 有 items = 確認建單
|
||||
DB::transaction(function () use ($validated) {
|
||||
DB::transaction(function () use ($validated, $machine) {
|
||||
$prefix = 'RP-' . now()->format('Ymd') . '-';
|
||||
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
|
||||
->withTrashed()
|
||||
@ -1023,10 +1221,10 @@ class WarehouseController extends Controller
|
||||
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$order = ReplenishmentOrder::create([
|
||||
'company_id' => Auth::user()->company_id,
|
||||
'company_id' => $machine->company_id,
|
||||
'order_no' => $order_no,
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'machine_id' => $machine->id,
|
||||
'status' => ReplenishmentOrder::STATUS_PENDING,
|
||||
'note' => $validated['note'] ?? __('Auto Replenishment'),
|
||||
'created_by' => Auth::id(),
|
||||
|
||||
@ -371,6 +371,12 @@ 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);
|
||||
|
||||
@ -394,8 +400,8 @@ class MachineController extends Controller
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
|
||||
// Cache First: Get from cache or rebuild if missing
|
||||
$payload = $catalogService->getPayload($machine->company_id);
|
||||
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload)
|
||||
$payload = $catalogService->getMachinePayload($machine);
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
@ -501,6 +507,8 @@ class MachineController extends Controller
|
||||
'WelcomeGift' => (bool) ($s['welcome_gift_enabled'] ?? false), // 來店禮
|
||||
'MemberSystem' => (bool) ($s['member_system_enabled'] ?? false), // 會員系統
|
||||
'AmbientTemp' => (bool) ($s['ambient_temp_monitoring_enabled'] ?? false), // 環境溫度監控
|
||||
'PharmacyPickup' => (bool) ($s['pharmacy_pickup_enabled'] ?? false), // 領藥單(雲端建單,取物單模式下開關)
|
||||
'Subcabinet' => (bool) ($s['subcabinet_enabled'] ?? false), // 副櫃系統(格子櫃功能,基礎版授權開關)
|
||||
];
|
||||
|
||||
// 5-4 ShoppingMode:購物方式(頂層字串)
|
||||
@ -690,17 +698,114 @@ class MachineController extends Controller
|
||||
]);
|
||||
|
||||
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
|
||||
// 憑證的「核銷」將由 MQTT finalizeTransaction 流程處理,不再需要 B660 PUT
|
||||
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
|
||||
|
||||
$data = [
|
||||
'slot_no' => $pickupCode->slot_no, // 保留:既有綁貨道碼讀此欄(綁商品碼/領藥單為 null)
|
||||
'product_id' => $pickupCode->product_id, // 綁商品碼:App 據此自挑可出貨道(綁貨道碼為 null)
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'code_id' => $pickupCode->id, // 統一回傳 code_id
|
||||
'status' => $pickupCode->status,
|
||||
];
|
||||
|
||||
// === 領藥單(pharmacy_pickup)擴充:回傳多貨道清單 + 標記碼已領(僅領藥單,不影響既有單貨道流程) ===
|
||||
$order = $pickupCode->order;
|
||||
$isPharmacy = $order && $order->order_type === \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
|
||||
|
||||
if ($isPharmacy) {
|
||||
// Gate:機台須仍為「取物單模式 + 領藥單開關開啟」才放行領藥碼
|
||||
$settings = $machine->settings ?? [];
|
||||
$enabled = (($settings['shopping_mode'] ?? null) === 'pickup_sheet')
|
||||
&& (bool) ($settings['pharmacy_pickup_enabled'] ?? false);
|
||||
|
||||
if (!$enabled) {
|
||||
return response()->json(['success' => false, 'message' => 'Pharmacy pickup not enabled on this machine', 'code' => 403], 403);
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
// 解析不到可出貨貨道(機台無對應貨道/庫存不足)→ 不消耗領藥碼,回明確錯誤讓藥師處理
|
||||
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);
|
||||
}
|
||||
|
||||
// 不在「驗證/掃碼」當下核銷領藥碼。核銷統一改由機台實際出貨後的
|
||||
// MQTT finalize(TransactionService::finalizePharmacyDispense)標記為 used,
|
||||
// 這樣顧客掃碼後若未按「確定取貨」(逾時/取消、未出貨),領藥碼仍維持 active 可再次掃碼領取。
|
||||
// (原本在此處 usage_count++/轉 used 會在尚未取貨時就把碼鎖死,導致再掃顯示「已領」。)
|
||||
|
||||
$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,
|
||||
'data' => [
|
||||
'slot_no' => $pickupCode->slot_no,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'code_id' => $pickupCode->id, // 統一回傳 code_id
|
||||
'status' => 'active'
|
||||
]
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -747,6 +852,17 @@ 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,
|
||||
|
||||
@ -13,7 +13,7 @@ class PickupController extends Controller
|
||||
*/
|
||||
public function show($slug)
|
||||
{
|
||||
$pickupCode = PickupCode::with(['machine.slots.product'])
|
||||
$pickupCode = PickupCode::with(['machine.slots.product', 'product'])
|
||||
->where(function($query) use ($slug) {
|
||||
$query->where('slug', $slug)
|
||||
->orWhere('code', $slug); // 相容舊版或測試
|
||||
|
||||
@ -147,7 +147,7 @@ class ProcessCommandAck implements ShouldQueue
|
||||
]);
|
||||
|
||||
// 記錄維護類指令到機台日誌 (MachineLog)
|
||||
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
|
||||
if (in_array($command->command_type, ['reboot', 'reboot_force', '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';
|
||||
|
||||
|
||||
@ -54,6 +54,14 @@ 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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -401,6 +409,11 @@ 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);
|
||||
|
||||
58
app/Models/Machine/MachineProductPrice.php
Normal file
58
app/Models/Machine/MachineProductPrice.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -20,12 +20,19 @@ 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()
|
||||
|
||||
@ -10,11 +10,14 @@ 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',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -25,6 +28,14 @@ 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.
|
||||
*/
|
||||
@ -35,4 +46,43 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,8 @@ class User extends Authenticatable
|
||||
*/
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'parent_id',
|
||||
'level',
|
||||
'username',
|
||||
'name',
|
||||
'email',
|
||||
@ -53,8 +55,14 @@ class User extends Authenticatable
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
'level' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 帳號層級上限:子帳號最多兩層(主帳號 level 0 → 子 level 1 → 孫 level 2,level 2 不可再建)。
|
||||
*/
|
||||
public const MAX_LEVEL = 2;
|
||||
|
||||
/**
|
||||
* Get the login logs for the user.
|
||||
*/
|
||||
@ -95,6 +103,85 @@ 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.
|
||||
*/
|
||||
|
||||
@ -6,13 +6,14 @@ 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;
|
||||
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
|
||||
@ -6,12 +6,18 @@ 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;
|
||||
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
|
||||
|
||||
// 訂單類型 (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;
|
||||
@ -30,6 +36,8 @@ class Order extends Model
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
// 領藥單專用:已開立、等待病人到機台領取(非 pending,不被逾時清掃;非終態,可被出貨 finalize 更新)。
|
||||
public const STATUS_AWAITING_PICKUP = 'awaiting_pickup';
|
||||
|
||||
/** 終態清單:到了這些狀態就不再變更(冪等)。 */
|
||||
public const TERMINAL_STATUSES = [
|
||||
@ -42,6 +50,9 @@ class Order extends Model
|
||||
'company_id',
|
||||
'flow_id',
|
||||
'order_no',
|
||||
'order_type',
|
||||
'pricing_slip_no',
|
||||
'created_by',
|
||||
'machine_id',
|
||||
'member_id',
|
||||
'payment_type',
|
||||
@ -49,6 +60,7 @@ class Order extends Model
|
||||
'discount_amount',
|
||||
'pay_amount',
|
||||
'change_amount',
|
||||
'cash_detail',
|
||||
'points_used',
|
||||
'original_amount',
|
||||
'payment_status',
|
||||
@ -71,6 +83,7 @@ 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',
|
||||
@ -79,6 +92,42 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有支付類型的對照標籤
|
||||
*/
|
||||
@ -182,11 +231,35 @@ class Order extends Model
|
||||
. mb_substr($name, -1);
|
||||
}
|
||||
|
||||
/** 一般銷售訂單 */
|
||||
public function scopeSales($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_SALE);
|
||||
}
|
||||
|
||||
/** 領藥單 */
|
||||
public function scopePharmacyPickup($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_PHARMACY_PICKUP);
|
||||
}
|
||||
|
||||
/** 是否為後台手動補單 */
|
||||
public function getIsManualAttribute(): bool
|
||||
{
|
||||
return $this->order_type === self::TYPE_MANUAL;
|
||||
}
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/** 建單者 (領藥單藥師) */
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
@ -207,6 +280,12 @@ class Order extends Model
|
||||
return $this->hasMany(DispenseRecord::class);
|
||||
}
|
||||
|
||||
/** 領藥單對應的領藥碼(一單一碼) */
|
||||
public function pickupCode()
|
||||
{
|
||||
return $this->hasOne(PickupCode::class);
|
||||
}
|
||||
|
||||
public function staffCardLog()
|
||||
{
|
||||
return $this->hasOne(\App\Models\StaffCardLog::class);
|
||||
|
||||
@ -14,6 +14,7 @@ class OrderItem extends Model
|
||||
'order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'slot_no',
|
||||
'barcode',
|
||||
'price',
|
||||
'quantity',
|
||||
|
||||
@ -18,8 +18,11 @@ class PassCode extends Model
|
||||
'name',
|
||||
'code',
|
||||
'slug',
|
||||
'batch_no',
|
||||
'expires_at',
|
||||
'status',
|
||||
'usage_limit', // 可用次數上限;null=無限次。勾「只能使用一次」時=1
|
||||
'usage_count', // 已使用次數
|
||||
'created_by',
|
||||
];
|
||||
|
||||
@ -76,6 +79,11 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@ -15,9 +15,12 @@ class PickupCode extends Model
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'code',
|
||||
'slug',
|
||||
'batch_no',
|
||||
'status',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'usage_limit',
|
||||
@ -57,6 +60,14 @@ class PickupCode extends Model
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯商品(取貨碼改綁商品時使用)
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Product\Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯建立者
|
||||
*/
|
||||
@ -78,9 +89,9 @@ class PickupCode extends Model
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->status === 'active'
|
||||
return $this->status === 'active'
|
||||
&& ($this->usage_count < $this->usage_limit)
|
||||
&& ($this->expires_at->isFuture());
|
||||
&& ($this->expires_at?->isFuture() ?? true); // null expires_at = 無到期限制
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -338,17 +338,31 @@ 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) {
|
||||
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged || $isLwwChanged) {
|
||||
$existingSlot->update($updateData);
|
||||
$existingSlot->refresh();
|
||||
|
||||
@ -392,10 +406,12 @@ class MachineService
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
// 找出資料庫中屬於此機台但不在清單內的舊貨道
|
||||
$orphanedSlots = $machine->slots()
|
||||
->whereNotIn('slot_no', $reportedSlotNos)
|
||||
->get();
|
||||
// 防呆:上報清單為空時不執行全量刪除(避免機台/APP 誤報空清單而把所有貨道清光)。
|
||||
$orphanedSlots = empty($reportedSlotNos)
|
||||
? collect()
|
||||
: $machine->slots()
|
||||
->whereNotIn('slot_no', $reportedSlotNos)
|
||||
->get();
|
||||
|
||||
foreach ($orphanedSlots as $orphan) {
|
||||
$oldStock = (int) $orphan->stock;
|
||||
@ -420,6 +436,88 @@ 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 現場值。
|
||||
* - 既有貨道 → 常態 LWW:App 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()
|
||||
@ -508,27 +606,55 @@ class MachineService
|
||||
->first();
|
||||
|
||||
if ($pendingCommand) {
|
||||
// 直接覆蓋:將前一個尚未執行的指令標記為「已取代」
|
||||
// 重新下發時覆蓋前一筆尚未執行的指令。
|
||||
// 若舊指令已超過 1 分鐘未收到機台回傳 (ACK),視為逾時 (timeout);
|
||||
// 否則為使用者主動覆蓋 (superseded)。與 dispense 逾時邏輯一致。
|
||||
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
|
||||
$pendingCommand->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new command'),
|
||||
'status' => $isTimedOut ? 'timeout' : 'superseded',
|
||||
'note' => $isTimedOut
|
||||
? __('Superseded by new command (Timeout)')
|
||||
: __('Superseded by new command'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 紀錄舊數據以供回滾使用
|
||||
$oldExpiry = $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null;
|
||||
$oldData = [
|
||||
'stock' => $slot->stock,
|
||||
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
|
||||
'expiry_date' => $oldExpiry,
|
||||
'batch_no' => $slot->batch_no,
|
||||
'is_locked' => (bool) $slot->is_locked,
|
||||
];
|
||||
|
||||
// 2. 執行樂觀更新 (Optimistic Update)
|
||||
$updateData = [
|
||||
'expiry_date' => $expiryDate,
|
||||
'batch_no' => $batchNo,
|
||||
];
|
||||
if ($stock !== null) $updateData['stock'] = (int)$stock;
|
||||
$slot->update($updateData);
|
||||
// 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);
|
||||
|
||||
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
|
||||
if ($stock !== null && (int)$stock !== $oldData['stock']) {
|
||||
@ -544,6 +670,8 @@ class MachineService
|
||||
);
|
||||
}
|
||||
|
||||
$slot->refresh();
|
||||
|
||||
// 3. 建立遠端指令紀錄
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
@ -554,19 +682,23 @@ class MachineService
|
||||
'slot_no' => (string)$slotNo,
|
||||
'old' => $oldData,
|
||||
'new' => [
|
||||
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
|
||||
'expiry_date' => $expiryDate ?: null,
|
||||
'batch_no' => $batchNo ?: null,
|
||||
'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,
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
// 4. 推播 MQTT
|
||||
// 4. 推播 MQTT(一律帶上目前權威的 is_locked 與雙 rev,讓 App 端對齊 synced_rev)
|
||||
$mqttPayload = [
|
||||
'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,
|
||||
'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,
|
||||
];
|
||||
|
||||
app(\App\Services\Machine\MqttService::class)->pushCommand(
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
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;
|
||||
@ -76,6 +78,54 @@ 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.
|
||||
*
|
||||
|
||||
304
app/Services/Transaction/PharmacyPickupService.php
Normal file
304
app/Services/Transaction/PharmacyPickupService.php
Normal file
@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineSlot;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Models\Transaction\PickupCode;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* 領藥單建立服務。
|
||||
*
|
||||
* 設計重點(對齊交叉檢查修正):
|
||||
* - 領藥單序號 = orders.flow_id(唯一),讓後續出貨 finalize 能套用既有終態冪等保護。
|
||||
* - 建單時「不」預扣庫存;庫存僅在機台出貨 finalize 時扣一次(避免雙重扣減)。
|
||||
* - 以「商品」為單位建單,貨道於 B660 掃碼時再由 product_id × machine_slots 解析(一張單可多貨道)。
|
||||
*/
|
||||
class PharmacyPickupService
|
||||
{
|
||||
/** 領藥單支付類型碼:沿用 42 = 領藥憑證 (Pickup Voucher)。 */
|
||||
public const PAYMENT_TYPE_PHARMACY = 42;
|
||||
|
||||
/**
|
||||
* 取得某機台「已生成但尚未領取(awaiting_pickup)」的領藥單預留量(依商品彙總)。
|
||||
* 用於建單時「預扣」顯示與防超領:可用 = 實體庫存 − 預留量。
|
||||
*/
|
||||
public function reservedQtyMap(Machine $machine): array
|
||||
{
|
||||
$map = [];
|
||||
$orders = Order::where('machine_id', $machine->id)
|
||||
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
|
||||
->where('status', Order::STATUS_AWAITING_PICKUP)
|
||||
->with('items')
|
||||
->get();
|
||||
|
||||
foreach ($orders as $order) {
|
||||
foreach ($order->items as $item) {
|
||||
$map[$item->product_id] = ($map[$item->product_id] ?? 0) + (int) $item->quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得某機台目前「有庫存」的可領藥品(依商品彙總,跨貨道加總庫存)。
|
||||
* available 已扣除「尚未領取的領藥單預留量」(生成領藥單即預扣)。
|
||||
*/
|
||||
public function availableProducts(Machine $machine): Collection
|
||||
{
|
||||
$reserved = $this->reservedQtyMap($machine);
|
||||
|
||||
return MachineSlot::where('machine_id', $machine->id)
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->with('product')
|
||||
->get()
|
||||
->groupBy('product_id')
|
||||
->map(function ($slots) use ($reserved) {
|
||||
$product = $slots->first()->product;
|
||||
if (!$product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$physical = (int) $slots->sum('stock');
|
||||
$reservedQty = (int) ($reserved[$product->id] ?? 0);
|
||||
$available = max(0, $physical - $reservedQty);
|
||||
|
||||
return (object) [
|
||||
'product_id' => $product->id,
|
||||
'name' => $product->localized_name ?? $product->name,
|
||||
'spec' => $product->localized_spec ?? $product->spec,
|
||||
'barcode' => $product->barcode,
|
||||
'price' => (float) ($product->price ?? 0),
|
||||
'available' => $available, // 預扣後可用(= 實體 − 預留)
|
||||
'physical' => $physical, // 實體庫存
|
||||
'reserved' => $reservedQty, // 已生成領藥單預留量
|
||||
'slot_count' => $slots->count(),
|
||||
'slots' => $slots->pluck('slot_no')->implode(', '),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->sortBy('name')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立一張領藥單:Order(pharmacy_pickup) + N 個 OrderItem + 1 個 PickupCode。
|
||||
*
|
||||
* @param array{machine_id:int, pricing_slip_no?:string, items:array<array{product_id:int, quantity:int}>, expires_at?:\DateTimeInterface} $data
|
||||
*/
|
||||
public function create(array $data, int $userId): Order
|
||||
{
|
||||
$machine = Machine::findOrFail($data['machine_id']);
|
||||
$items = collect($data['items'] ?? [])
|
||||
->map(fn ($i) => ['product_id' => (int) $i['product_id'], 'quantity' => (int) $i['quantity']])
|
||||
->filter(fn ($i) => $i['quantity'] > 0)
|
||||
->values();
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('Please select at least one medicine with quantity.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($machine, $items, $data, $userId) {
|
||||
$orderItemsData = [];
|
||||
$total = 0.0;
|
||||
|
||||
// 既有「尚未領取領藥單」的預留量(本單尚未建立,不含本單)→ 可用 = 實體 − 預留,防超領。
|
||||
$reserved = $this->reservedQtyMap($machine);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$product = Product::findOrFail($item['product_id']);
|
||||
$qty = $item['quantity'];
|
||||
|
||||
// 該機台此商品跨貨道可用庫存 = 實體庫存 − 已生成領藥單預留量
|
||||
$physical = (int) MachineSlot::where('machine_id', $machine->id)
|
||||
->where('product_id', $product->id)
|
||||
->where('is_active', true)
|
||||
->sum('stock');
|
||||
$available = $physical - (int) ($reserved[$product->id] ?? 0);
|
||||
|
||||
if ($qty > $available) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('Insufficient stock for :name (requested :qty, available :available).', [
|
||||
'name' => $product->localized_name ?? $product->name,
|
||||
'qty' => $qty,
|
||||
'available' => $available,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
$price = (float) ($product->price ?? 0);
|
||||
$subtotal = $price * $qty;
|
||||
$total += $subtotal;
|
||||
|
||||
$orderItemsData[] = [
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->localized_name ?? $product->name,
|
||||
'barcode' => $product->barcode,
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'subtotal' => $subtotal,
|
||||
];
|
||||
}
|
||||
|
||||
$serial = $this->generateSerial();
|
||||
|
||||
$order = Order::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'flow_id' => $serial,
|
||||
'order_no' => $serial,
|
||||
'order_type' => Order::TYPE_PHARMACY_PICKUP,
|
||||
'pricing_slip_no' => $data['pricing_slip_no'] ?? null,
|
||||
'created_by' => $userId,
|
||||
'machine_id' => $machine->id,
|
||||
'payment_type' => self::PAYMENT_TYPE_PHARMACY,
|
||||
'total_amount' => $total,
|
||||
'discount_amount' => 0,
|
||||
'pay_amount' => 0,
|
||||
'change_amount' => 0,
|
||||
'points_used' => 0,
|
||||
'payment_status' => Order::PAYMENT_STATUS_SUCCESS,
|
||||
'status' => Order::STATUS_AWAITING_PICKUP,
|
||||
'delivery_status' => Order::DELIVERY_STATUS_FAILED, // 0:尚未出貨
|
||||
'machine_time' => now(),
|
||||
]);
|
||||
|
||||
foreach ($orderItemsData as $oi) {
|
||||
$order->items()->create($oi);
|
||||
}
|
||||
|
||||
$pickupCode = PickupCode::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'slot_no' => null, // 多貨道:由 B660 掃碼時解析
|
||||
'code' => PickupCode::generateUniqueCode($machine->id),
|
||||
'status' => 'active',
|
||||
'expires_at' => $data['expires_at'] ?? now()->endOfDay(), // 預設當日有效
|
||||
'usage_limit' => 1,
|
||||
'usage_count' => 0,
|
||||
'created_by' => $userId,
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'user_id' => $userId,
|
||||
'module' => 'pharmacy_pickup',
|
||||
'action' => 'create',
|
||||
'target_id' => $order->id,
|
||||
'target_type' => Order::class,
|
||||
'new_values' => [
|
||||
'order_no' => $order->order_no,
|
||||
'pricing_slip_no' => $order->pricing_slip_no,
|
||||
'pickup_code' => $pickupCode->code,
|
||||
'items' => $orderItemsData,
|
||||
],
|
||||
]);
|
||||
|
||||
return $order->load(['items', 'pickupCode', 'machine']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查一張領藥單在「目前機台貨道庫存」下是否有商品庫存不足(無法整單出貨)。
|
||||
* 回傳不足清單:[['product_name'=>, 'required'=>, 'available'=>], ...];空陣列表示可整單出貨。
|
||||
* 用於 B660 掃碼出貨前的嚴格擋關:任何商品不足即整單擋下、不出貨、不消耗領藥碼。
|
||||
*/
|
||||
public function dispenseShortage(Order $order): array
|
||||
{
|
||||
$short = [];
|
||||
|
||||
foreach ($order->items as $orderItem) {
|
||||
$available = (int) MachineSlot::where('machine_id', $order->machine_id)
|
||||
->where('product_id', $orderItem->product_id)
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->sum('stock');
|
||||
|
||||
if ($available < (int) $orderItem->quantity) {
|
||||
$short[] = [
|
||||
'product_name' => $orderItem->product_name,
|
||||
'required' => (int) $orderItem->quantity,
|
||||
'available' => $available,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $short;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一張領藥單在「目前機台貨道庫存」下的出貨清單(一商品可跨多貨道湊量)。
|
||||
* 回傳:[['slot_no'=>, 'product_id'=>, 'product_name'=>, 'qty'=>], ...]
|
||||
* 庫存不足時盡力分配(出可出的數量;部分/失敗由出貨回報 Phase 標記)。
|
||||
*/
|
||||
public function resolveDispenseItems(Order $order): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($order->items as $orderItem) {
|
||||
$remaining = (int) $orderItem->quantity;
|
||||
|
||||
$slots = MachineSlot::where('machine_id', $order->machine_id)
|
||||
->where('product_id', $orderItem->product_id)
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->orderByDesc('stock')
|
||||
->orderBy('slot_no')
|
||||
->get();
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
if ($remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
$take = min($remaining, (int) $slot->stock);
|
||||
if ($take <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'slot_no' => $slot->slot_no,
|
||||
'product_id' => $orderItem->product_id,
|
||||
'product_name' => $orderItem->product_name,
|
||||
'qty' => $take,
|
||||
];
|
||||
$remaining -= $take;
|
||||
}
|
||||
// $remaining > 0 表示目前庫存不足以完全滿足此商品,僅回傳可出的部分。
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生領藥單序號:RX + 西元年月日 + 當日 4 碼流水(與 flow_id 唯一性衝突時自動遞增)。
|
||||
*/
|
||||
protected function generateSerial(): string
|
||||
{
|
||||
$datePart = now()->format('Ymd');
|
||||
$base = Order::withoutGlobalScopes()
|
||||
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
|
||||
->whereDate('created_at', now()->toDateString())
|
||||
->count();
|
||||
|
||||
$attempt = 0;
|
||||
do {
|
||||
$seq = $base + 1 + $attempt;
|
||||
$serial = 'RX' . $datePart . str_pad((string) $seq, 4, '0', STR_PAD_LEFT);
|
||||
$attempt++;
|
||||
} while (
|
||||
Order::withoutGlobalScopes()->where('flow_id', $serial)->exists()
|
||||
&& $attempt < 100
|
||||
);
|
||||
|
||||
return $serial;
|
||||
}
|
||||
}
|
||||
@ -71,6 +71,8 @@ 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),
|
||||
@ -118,7 +120,14 @@ 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'])
|
||||
@ -153,6 +162,8 @@ 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'],
|
||||
@ -303,18 +314,40 @@ class TransactionService
|
||||
// 執行實體庫存扣除 (真實交易化)
|
||||
if ((int) ($data['dispense_status'] ?? 0) === 1) {
|
||||
if ($slot) {
|
||||
$slot->decrement('stock');
|
||||
// 防呆:庫存已為 0(或更低)時不再扣減,避免出現負庫存(-1)。
|
||||
// 正常情況下 B660 已嚴格擋下庫存不足的領藥單;此處為下位機與後台庫存不同步時的最後防線。
|
||||
if ((int) $slot->stock > 0) {
|
||||
$slot->decrement('stock');
|
||||
|
||||
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
|
||||
// 售完(庫存歸 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,
|
||||
]);
|
||||
}
|
||||
|
||||
$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]
|
||||
);
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Log warning if slot matching fails to help diagnostic
|
||||
\Log::warning("Dispense record created but slot not found for decrement", [
|
||||
@ -367,6 +400,31 @@ 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),也走領藥流程。
|
||||
if ($existingOrder && $existingOrder->order_type === Order::TYPE_PHARMACY_PICKUP) {
|
||||
return $this->finalizePharmacyDispense($existingOrder, $data);
|
||||
}
|
||||
|
||||
// 1. Process Order (B600)
|
||||
$orderData = $data['order'];
|
||||
$orderData['serial_no'] = $serialNo;
|
||||
@ -408,6 +466,7 @@ 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) {
|
||||
@ -420,6 +479,10 @@ class TransactionService
|
||||
$dispenseItem['member_barcode'] = $order->member_barcode;
|
||||
}
|
||||
|
||||
if (!empty($dispenseItem['slot_no'])) {
|
||||
$dispensedSlots[] = (string) $dispenseItem['slot_no'];
|
||||
}
|
||||
|
||||
$this->recordDispense($dispenseItem);
|
||||
}
|
||||
}
|
||||
@ -471,7 +534,11 @@ class TransactionService
|
||||
'action' => $isUsedUp ? 'used' : 'consume',
|
||||
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
|
||||
'raw_data' => [
|
||||
'slot' => $pickupCode->slot_no,
|
||||
// 取貨碼綁「商品」時 pickupCode->slot_no 為 null;
|
||||
// 改記「實際出貨的貨道/櫃號」(dispense),取不到才退回取貨碼綁定貨道。
|
||||
'slot' => !empty($dispensedSlots)
|
||||
? implode(', ', array_unique($dispensedSlots))
|
||||
: $pickupCode->slot_no,
|
||||
'code' => $pickupCode->code,
|
||||
'count' => $newCount,
|
||||
'limit' => $pickupCode->usage_limit
|
||||
@ -481,6 +548,8 @@ class TransactionService
|
||||
break;
|
||||
|
||||
case 5: // 通行碼 (Pass Code)
|
||||
// 注意:通行碼「用掉一次」的計數改在 B670 驗證(verifyPassCode)當下處理,
|
||||
// 因為 App 對通行碼會略過交易結案上報,此 case 5 實務上不會被觸發。
|
||||
// 建立通行碼核銷日誌
|
||||
PassCodeLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
@ -533,6 +602,186 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 領藥單出貨回報(獨立於銷售流程)。
|
||||
*
|
||||
* 機台逐道出貨後,以 transaction/finalize 帶 dispense[](每出貨一單位一筆,含 slot_no/dispense_status)回報。
|
||||
* 本方法:建立出貨紀錄(沿用 recordDispense:成功則扣 1 庫存)→ 彙整 delivery_status →
|
||||
* 將訂單轉為終態 completed(冪等:重送會在 finalizeTransaction 開頭 early-return)→ 標記領藥碼已領 + 核銷日誌。
|
||||
*/
|
||||
protected function finalizePharmacyDispense(Order $order, array $data): Order
|
||||
{
|
||||
return DB::transaction(function () use ($order, $data) {
|
||||
$serialNo = $data['serial_no'] ?? $order->machine?->serial_no;
|
||||
|
||||
if (!empty($data['dispense'])) {
|
||||
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
|
||||
foreach ($dispenseList as $dispenseItem) {
|
||||
$dispenseItem['serial_no'] = $serialNo;
|
||||
$dispenseItem['flow_id'] = $order->flow_id;
|
||||
$dispenseItem['order_id'] = $order->id;
|
||||
$this->recordDispense($dispenseItem); // 建 DispenseRecord;dispense_status=1 時扣 1 庫存
|
||||
}
|
||||
$deliveryStatus = $this->resolveDeliveryStatusFromDispense($data['dispense']);
|
||||
} else {
|
||||
// 無出貨明細視為失敗
|
||||
$deliveryStatus = Order::DELIVERY_STATUS_FAILED;
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'delivery_status' => $deliveryStatus,
|
||||
'status' => Order::STATUS_COMPLETED, // 終態 → 後續重送冪等
|
||||
'machine_time' => $data['machine_time'] ?? $order->machine_time,
|
||||
]);
|
||||
|
||||
// 標記領藥碼已領(B660 通常已標 used;此處保險並留核銷日誌)
|
||||
$pickupCode = $order->pickupCode;
|
||||
if ($pickupCode) {
|
||||
if ($pickupCode->status !== 'used') {
|
||||
$pickupCode->update(['status' => 'used', 'used_at' => $pickupCode->used_at ?? now()]);
|
||||
}
|
||||
PickupCodeLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
'machine_id' => $order->machine_id,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'order_id' => $order->id,
|
||||
'action' => 'used',
|
||||
'remark' => "log.pickup.pharmacy_dispensed",
|
||||
'raw_data' => [
|
||||
'order_no' => $order->order_no,
|
||||
'delivery_status' => $deliveryStatus,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('Pharmacy pickup dispense finalized', [
|
||||
'flow_id' => $order->flow_id,
|
||||
'delivery_status' => $deliveryStatus,
|
||||
]);
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve order-level delivery status from item-level dispense results.
|
||||
*/
|
||||
|
||||
28
app/Traits/HasDisplayFlowId.php
Normal file
28
app/Traits/HasDisplayFlowId.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -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(環境溫度監控)",
|
||||
'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 取物單模式下有效)",
|
||||
],
|
||||
'data.ShoppingMode' => [
|
||||
'type' => 'string',
|
||||
@ -258,6 +258,7 @@ return [
|
||||
'WelcomeGift' => false,
|
||||
'MemberSystem' => false,
|
||||
'AmbientTemp' => false,
|
||||
'PharmacyPickup' => false,
|
||||
],
|
||||
'ShoppingMode' => 'basic',
|
||||
'LangSet' => [
|
||||
|
||||
@ -15,7 +15,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_DRIVER', 'file'),
|
||||
'default' => env('CACHE_STORE', env('CACHE_DRIVER', 'database')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 領藥單模組:orders 增加訂單類型/批價單號/建單者欄位。
|
||||
* order_type 預設 sale,既有資料一律視為一般銷售,不影響線上行為。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('orders', 'order_type')) {
|
||||
$table->string('order_type', 20)->default('sale')->index()
|
||||
->comment('訂單類型: sale=一般銷售, pharmacy_pickup=領藥單')->after('order_no');
|
||||
}
|
||||
if (!Schema::hasColumn('orders', 'pricing_slip_no')) {
|
||||
$table->string('pricing_slip_no', 64)->nullable()
|
||||
->comment('批價單號 (領藥單藥師填寫之依據)')->after('order_type');
|
||||
}
|
||||
if (!Schema::hasColumn('orders', 'created_by')) {
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()
|
||||
->comment('建單者 (領藥單由後台藥師建立)')->after('pricing_slip_no');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('orders', 'created_by')) {
|
||||
$table->dropForeign(['created_by']);
|
||||
$table->dropColumn('created_by');
|
||||
}
|
||||
if (Schema::hasColumn('orders', 'pricing_slip_no')) {
|
||||
$table->dropColumn('pricing_slip_no');
|
||||
}
|
||||
if (Schema::hasColumn('orders', 'order_type')) {
|
||||
$table->dropColumn('order_type');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 領藥單一張單多貨道:slot_no 改為可空。
|
||||
* 一般取貨碼仍寫入 slot_no(單貨道);領藥單的多貨道由 order_items × machine_slots 解析,
|
||||
* 故 slot_no 允許為 null,讀取端依 order_type 區分。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
$table->string('slot_no')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
$table->string('slot_no')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?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
|
||||
{
|
||||
// 資料校正,無法可靠還原
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,113 @@
|
||||
<?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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,60 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
38
database/migrations/2026_06_26_130000_create_cache_table.php
Normal file
38
database/migrations/2026_06_26_130000_create_cache_table.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 建立 Laravel 標準 cache / cache_locks 表。
|
||||
* 對應 dev 的修正 55cf6b8:CACHE_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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
<?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) {
|
||||
// 消費者選擇的貨道/格子號(格子櫃=櫃號如 501,VMC=貨道號)。
|
||||
// 由 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
<?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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -39,6 +39,7 @@ class RoleSeeder extends Seeder
|
||||
'menu.sales.promotions',
|
||||
'menu.sales.pass-codes',
|
||||
'menu.sales.store-gifts',
|
||||
'menu.sales.pharmacy-pickup',
|
||||
'menu.analysis',
|
||||
'menu.analysis.change-stock',
|
||||
'menu.analysis.machine-reports',
|
||||
|
||||
123
lang/en.json
123
lang/en.json
@ -1,4 +1,14 @@
|
||||
{
|
||||
"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)",
|
||||
@ -110,9 +120,11 @@
|
||||
"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",
|
||||
@ -216,9 +228,12 @@
|
||||
"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",
|
||||
@ -265,6 +280,7 @@
|
||||
"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",
|
||||
@ -273,6 +289,7 @@
|
||||
"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",
|
||||
@ -320,6 +337,7 @@
|
||||
"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",
|
||||
@ -357,6 +375,7 @@
|
||||
"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",
|
||||
@ -424,10 +443,13 @@
|
||||
"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",
|
||||
@ -440,6 +462,7 @@
|
||||
"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",
|
||||
@ -509,6 +532,7 @@
|
||||
"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",
|
||||
@ -520,6 +544,7 @@
|
||||
"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",
|
||||
@ -601,7 +626,10 @@
|
||||
"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",
|
||||
@ -749,14 +777,21 @@
|
||||
"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.",
|
||||
@ -868,6 +903,7 @@
|
||||
"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",
|
||||
@ -889,6 +925,7 @@
|
||||
"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",
|
||||
@ -927,10 +964,13 @@
|
||||
"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",
|
||||
@ -951,8 +991,12 @@
|
||||
"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",
|
||||
@ -971,8 +1015,13 @@
|
||||
"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",
|
||||
@ -1008,6 +1057,7 @@
|
||||
"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",
|
||||
@ -1031,15 +1081,18 @@
|
||||
"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",
|
||||
@ -1077,6 +1130,7 @@
|
||||
"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",
|
||||
@ -1092,6 +1146,8 @@
|
||||
"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",
|
||||
@ -1110,6 +1166,7 @@
|
||||
"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.",
|
||||
@ -1122,6 +1179,8 @@
|
||||
"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",
|
||||
@ -1165,9 +1224,13 @@
|
||||
"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",
|
||||
@ -1185,6 +1248,7 @@
|
||||
"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",
|
||||
@ -1238,6 +1302,7 @@
|
||||
"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",
|
||||
@ -1310,6 +1375,7 @@
|
||||
"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",
|
||||
@ -1317,24 +1383,29 @@
|
||||
"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",
|
||||
@ -1365,6 +1436,7 @@
|
||||
"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",
|
||||
@ -1396,6 +1468,7 @@
|
||||
"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.",
|
||||
@ -1414,6 +1487,7 @@
|
||||
"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",
|
||||
@ -1495,6 +1569,7 @@
|
||||
"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",
|
||||
@ -1515,6 +1590,7 @@
|
||||
"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.",
|
||||
@ -1535,13 +1611,25 @@
|
||||
"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",
|
||||
@ -1584,8 +1672,10 @@
|
||||
"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",
|
||||
@ -1604,8 +1694,10 @@
|
||||
"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",
|
||||
@ -1713,6 +1805,7 @@
|
||||
"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",
|
||||
@ -1792,7 +1885,9 @@
|
||||
"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",
|
||||
@ -1804,6 +1899,7 @@
|
||||
"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",
|
||||
@ -1811,6 +1907,8 @@
|
||||
"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",
|
||||
@ -1820,6 +1918,7 @@
|
||||
"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.",
|
||||
@ -1897,6 +1996,8 @@
|
||||
"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",
|
||||
@ -1906,6 +2007,8 @@
|
||||
"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",
|
||||
@ -1973,6 +2076,7 @@
|
||||
"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",
|
||||
@ -2041,6 +2145,7 @@
|
||||
"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",
|
||||
@ -2049,6 +2154,7 @@
|
||||
"Submachine Exception": "Submachine Exception",
|
||||
"Submachine Exception (B013)": "Submachine Exception (B013)",
|
||||
"Submit Record": "Submit Record",
|
||||
"Submitting...": "Submitting...",
|
||||
"Subtotal": "Subtotal",
|
||||
"Success": "Success",
|
||||
"Super Admin": "Super Admin",
|
||||
@ -2102,6 +2208,7 @@
|
||||
"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",
|
||||
@ -2127,18 +2234,24 @@
|
||||
"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.",
|
||||
@ -2390,9 +2503,12 @@
|
||||
"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",
|
||||
@ -2422,6 +2538,7 @@
|
||||
"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",
|
||||
@ -2453,6 +2570,7 @@
|
||||
"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",
|
||||
@ -2528,6 +2646,7 @@
|
||||
"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",
|
||||
@ -2558,12 +2677,15 @@
|
||||
"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",
|
||||
@ -2573,6 +2695,7 @@
|
||||
"product_updated": "product_updated",
|
||||
"remote": "Remote Management",
|
||||
"reservation": "Reservation System",
|
||||
"reserved": "reserved",
|
||||
"roles": "Roles",
|
||||
"s": "s",
|
||||
"sales": "Sales Management",
|
||||
|
||||
109
lang/ja.json
109
lang/ja.json
@ -1,4 +1,14 @@
|
||||
{
|
||||
"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)",
|
||||
@ -110,9 +120,11 @@
|
||||
"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": "すべてのスロット",
|
||||
@ -216,9 +228,12 @@
|
||||
"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": "バッジ設定",
|
||||
@ -265,6 +280,7 @@
|
||||
"Cancel Pass Code": "パスコードキャンセル",
|
||||
"Cancel Pickup Code": "受取コードキャンセル",
|
||||
"Cancel Purchase": "購入キャンセル",
|
||||
"Cancel this pharmacy pickup order?": "この薬品受取注文を取り消しますか?",
|
||||
"Cancelled": "キャンセル済み",
|
||||
"Cannot Delete Role": "ロールを削除できません",
|
||||
"Cannot cancel this order": "この注文はキャンセルできません",
|
||||
@ -273,6 +289,7 @@
|
||||
"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": "カードリーダーシステム",
|
||||
@ -320,6 +337,7 @@
|
||||
"Choose File": "ファイルを選択",
|
||||
"Choose Logo": "ロゴを選択",
|
||||
"Choose Overall BG": "全体背景画像を選択",
|
||||
"Citizen certificate carrier": "自然人証明書キャリア",
|
||||
"Clear": "クリア",
|
||||
"Clear Abnormal Status": "異常状態のクリア",
|
||||
"Clear Filter": "フィルターをクリア",
|
||||
@ -357,6 +375,7 @@
|
||||
"Company Level": "会社レベル",
|
||||
"Company Name": "会社名",
|
||||
"Company Settings": "会社グローバル設定",
|
||||
"Company Tax ID": "統一番号",
|
||||
"Complete movement history log": "完全な在庫変動履歴",
|
||||
"Completed": "完了済み",
|
||||
"Completed At": "完了日時",
|
||||
@ -424,10 +443,13 @@
|
||||
"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": "ロール作成",
|
||||
@ -440,6 +462,7 @@
|
||||
"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": "作成時間",
|
||||
@ -509,6 +532,7 @@
|
||||
"Date Range": "日付範囲",
|
||||
"Day Before": "一昨日",
|
||||
"Days": "日",
|
||||
"Deduct stock": "在庫を減算",
|
||||
"Default": "デフォルト",
|
||||
"Default BG": "デフォルト背景",
|
||||
"Default Card BG": "デフォルトカード背景",
|
||||
@ -520,6 +544,7 @@
|
||||
"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": "新しい外部決済パラメータを定義",
|
||||
@ -601,7 +626,10 @@
|
||||
"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": "扉が開きました",
|
||||
@ -753,10 +781,13 @@
|
||||
"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.": "権限の保存に失敗しました。",
|
||||
@ -868,6 +899,7 @@
|
||||
"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": "強制アップデート",
|
||||
@ -889,6 +921,7 @@
|
||||
"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": "戻る",
|
||||
@ -927,6 +960,7 @@
|
||||
"Identification": "識別情報",
|
||||
"Identity & Codes": "識別・コード",
|
||||
"Identity and Codes": "Identity and Codes",
|
||||
"Ignore busy state (use when stuck)": "ビジー状態を無視(フリーズ時に使用)",
|
||||
"Image": "画像",
|
||||
"Image Downloaded": "画像をダウンロードしました",
|
||||
"Immediate": "即時",
|
||||
@ -951,8 +985,12 @@
|
||||
"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": "在庫概要",
|
||||
@ -971,8 +1009,13 @@
|
||||
"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)",
|
||||
@ -1008,6 +1051,7 @@
|
||||
"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": "空白のままにすると会社のグローバル設定を継承します",
|
||||
@ -1031,15 +1075,18 @@
|
||||
"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": "記録時間",
|
||||
@ -1077,6 +1124,7 @@
|
||||
"Machine Flavor": "Machine Flavor",
|
||||
"Machine Flow ID": "機器フローID",
|
||||
"Machine Flow ID / Time": "機器フローID / 時間",
|
||||
"Machine Force Reboot": "アプリ強制再起動",
|
||||
"Machine Images": "機器写真",
|
||||
"Machine Inbound Confirmation": "入庫確認",
|
||||
"Machine Info": "機器情報",
|
||||
@ -1092,6 +1140,8 @@
|
||||
"Machine Model Settings": "機器モデル設定",
|
||||
"Machine Name": "機器名",
|
||||
"Machine Permissions": "機器権限",
|
||||
"Machine Price": "マシン価格",
|
||||
"Machine Pricing": "マシン価格設定",
|
||||
"Machine Reboot": "機器アプリ再起動",
|
||||
"Machine Registry": "機器リスト",
|
||||
"Machine Replenishment": "機器補充伝票",
|
||||
@ -1110,6 +1160,7 @@
|
||||
"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.": "マシンがサーバーに再接続し、通常のハートビートを再開しました。",
|
||||
@ -1122,6 +1173,8 @@
|
||||
"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": "機器から倉庫",
|
||||
@ -1165,9 +1218,13 @@
|
||||
"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": "資材コード",
|
||||
@ -1185,6 +1242,7 @@
|
||||
"Max Capacity:": "最大容量:",
|
||||
"Max Stock": "在庫上限",
|
||||
"Maximum file size: 100MB": "Maximum file size: 100MB",
|
||||
"Medicine": "薬品",
|
||||
"Member": "会員価格",
|
||||
"Member & External": "会員および外部システム",
|
||||
"Member + 1": "会員 + 1",
|
||||
@ -1238,6 +1296,7 @@
|
||||
"Missing RelateNumber, cannot query": "Missing RelateNumber, cannot query",
|
||||
"Mobile Pay": "モバイル決済",
|
||||
"Mobile Payment": "モバイル決済",
|
||||
"Mobile barcode carrier": "モバイルバーコードキャリア",
|
||||
"Model": "機器モデル",
|
||||
"Model Default": "モデルデフォルト",
|
||||
"Model Name": "モデル名",
|
||||
@ -1310,6 +1369,7 @@
|
||||
"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": "ログが見つかりません",
|
||||
@ -1317,24 +1377,29 @@
|
||||
"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": "備考なし",
|
||||
@ -1365,6 +1430,7 @@
|
||||
"Not Used": "使用しない",
|
||||
"Not Used Description": "サードパーティ決済を使用しない",
|
||||
"Not a co-branded card": "提携カードではありません",
|
||||
"Not stocked on this machine": "この機器に未陳列",
|
||||
"Note": "備考",
|
||||
"Note (optional)": "備考 (任意)",
|
||||
"Notes": "備考",
|
||||
@ -1396,6 +1462,7 @@
|
||||
"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.": "アカウントが削除されると、すべてのデータは永久に削除されます。完全に削除することを確認するためにパスワードを入力してください。",
|
||||
@ -1414,6 +1481,7 @@
|
||||
"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",
|
||||
@ -1495,6 +1563,7 @@
|
||||
"Page 69": "ページ 69 (購入キャンセル)",
|
||||
"Page 7": "ページ 7 (ロックページ)",
|
||||
"Page Lock Status": "ページロックステータス",
|
||||
"Paid": "支払い済み",
|
||||
"Parameters": "パラメータ設定",
|
||||
"Partial Dispense Success": "一部出庫成功",
|
||||
"Pass Code": "パスコード",
|
||||
@ -1535,10 +1604,15 @@
|
||||
"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": "受取コード",
|
||||
@ -1584,8 +1658,10 @@
|
||||
"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",
|
||||
@ -1604,8 +1680,10 @@
|
||||
"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",
|
||||
@ -1713,6 +1791,7 @@
|
||||
"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": "今すぐ更新",
|
||||
@ -1792,7 +1871,9 @@
|
||||
"Running Status": "稼働ステータス",
|
||||
"SYSTEM": "システムレベル",
|
||||
"Safety Stock": "安全在庫",
|
||||
"Sale Paused": "販売停止",
|
||||
"Sale Price": "販売価格",
|
||||
"Sale Status": "販売状態",
|
||||
"Sales": "販売管理",
|
||||
"Sales Activity": "販売活動",
|
||||
"Sales Amount": "売上金額",
|
||||
@ -1804,6 +1885,7 @@
|
||||
"Sales Today": "本日の販売数",
|
||||
"Sales revenue minus product cost net value": "販売額から商品コストを差し引いた純利益",
|
||||
"Save": "保存",
|
||||
"Save & Sync": "保存して同期",
|
||||
"Save Changes": "変更を保存",
|
||||
"Save Config": "設定を保存",
|
||||
"Save Material": "素材を保存",
|
||||
@ -1811,6 +1893,8 @@
|
||||
"Save Settings": "設定を保存",
|
||||
"Save Version": "Save Version",
|
||||
"Save error:": "保存エラー:",
|
||||
"Save failed": "保存に失敗しました",
|
||||
"Saved": "保存しました",
|
||||
"Saved.": "保存しました。",
|
||||
"Saving...": "保存中...",
|
||||
"Scale level and access control": "スケールレベルとアクセス制御",
|
||||
@ -1820,6 +1904,7 @@
|
||||
"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コードをスキャンすると、このデバイスのメンテナンスフォームに素早くアクセスできます。",
|
||||
@ -1897,6 +1982,8 @@
|
||||
"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": "この機器で再生する素材を選択",
|
||||
@ -1906,6 +1993,8 @@
|
||||
"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": "選択中",
|
||||
@ -1973,6 +2062,7 @@
|
||||
"Source Machine": "移動元自販機",
|
||||
"Source Warehouse": "移動元倉庫",
|
||||
"Source of temperature limit settings": "温度制限設定のソース",
|
||||
"Spec": "規格",
|
||||
"Special Permission": "特別権限",
|
||||
"Specification": "仕様",
|
||||
"Specifications": "仕様",
|
||||
@ -2041,6 +2131,7 @@
|
||||
"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",
|
||||
@ -2049,6 +2140,7 @@
|
||||
"Submachine Exception": "下位機ハードウェア異常",
|
||||
"Submachine Exception (B013)": "下位機/商品ラック異常 (B013)",
|
||||
"Submit Record": "記録を提出",
|
||||
"Submitting...": "送信中...",
|
||||
"Subtotal": "小計",
|
||||
"Success": "成功",
|
||||
"Super Admin": "特権管理者",
|
||||
@ -2102,6 +2194,7 @@
|
||||
"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": "アラート温度範囲",
|
||||
@ -2127,18 +2220,24 @@
|
||||
"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.": "このスロットには保留中の更新があります。前のコマンドが完了するまでお待ちください。",
|
||||
@ -2390,9 +2489,12 @@
|
||||
"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",
|
||||
@ -2422,6 +2524,7 @@
|
||||
"command.lock": "ロック",
|
||||
"command.reboot": "再起動",
|
||||
"command.reboot_card": "カードリーダー再起動",
|
||||
"command.reboot_force": "強制再起動",
|
||||
"command.reload_stock": "在庫同期",
|
||||
"command.unlock": "ロック解除",
|
||||
"command.update_ads": "広告同期",
|
||||
@ -2453,6 +2556,7 @@
|
||||
"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": "例:会社標準決済",
|
||||
@ -2528,6 +2632,7 @@
|
||||
"menu.sales": "販売管理",
|
||||
"menu.sales.orders": "購入注文",
|
||||
"menu.sales.pass-codes": "パスコード",
|
||||
"menu.sales.pharmacy-pickup": "薬品受取",
|
||||
"menu.sales.pickup-codes": "受取コード",
|
||||
"menu.sales.promotions": "プロモーション期間",
|
||||
"menu.sales.records": "販売記録",
|
||||
@ -2558,12 +2663,15 @@
|
||||
"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",
|
||||
@ -2573,6 +2681,7 @@
|
||||
"product_updated": "product_updated",
|
||||
"remote": "遠隔管理",
|
||||
"reservation": "予約システム",
|
||||
"reserved": "予約",
|
||||
"roles": "ロール権限",
|
||||
"s": "秒",
|
||||
"sales": "販売管理",
|
||||
|
||||
126
lang/zh_TW.json
126
lang/zh_TW.json
@ -1,4 +1,15 @@
|
||||
{
|
||||
"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)",
|
||||
@ -110,9 +121,11 @@
|
||||
"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": "所有貨道",
|
||||
@ -216,9 +229,12 @@
|
||||
"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": "識別證",
|
||||
@ -265,6 +281,7 @@
|
||||
"Cancel Pass Code": "取消通行碼",
|
||||
"Cancel Pickup Code": "取消取貨碼",
|
||||
"Cancel Purchase": "取消購買",
|
||||
"Cancel this pharmacy pickup order?": "確定要作廢這張領藥單嗎?",
|
||||
"Cancelled": "已取消",
|
||||
"Cannot Delete Role": "無法刪除該角色",
|
||||
"Cannot cancel this order": "無法取消此訂單",
|
||||
@ -273,6 +290,7 @@
|
||||
"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": "刷卡機系統",
|
||||
@ -320,6 +338,7 @@
|
||||
"Choose File": "選擇檔案",
|
||||
"Choose Logo": "選擇公司 Logo",
|
||||
"Choose Overall BG": "選擇大背景圖",
|
||||
"Citizen certificate carrier": "自然人憑證載具",
|
||||
"Clear": "清除",
|
||||
"Clear Abnormal Status": "清除異常狀態",
|
||||
"Clear Filter": "清除篩選",
|
||||
@ -357,6 +376,7 @@
|
||||
"Company Level": "公司層級",
|
||||
"Company Name": "公司名稱",
|
||||
"Company Settings": "公司全域設定",
|
||||
"Company Tax ID": "統一編號",
|
||||
"Complete movement history log": "完整的庫存異動歷程",
|
||||
"Completed": "已完成",
|
||||
"Completed At": "完成時間",
|
||||
@ -424,10 +444,13 @@
|
||||
"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": "建立角色",
|
||||
@ -440,6 +463,7 @@
|
||||
"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": "建立時間",
|
||||
@ -509,6 +533,7 @@
|
||||
"Date Range": "日期區間",
|
||||
"Day Before": "前日",
|
||||
"Days": "天",
|
||||
"Deduct stock": "扣除庫存",
|
||||
"Default": "預設",
|
||||
"Default BG": "預設大背景",
|
||||
"Default Card BG": "預設卡片背景",
|
||||
@ -520,6 +545,7 @@
|
||||
"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": "定義新的第三方支付參數",
|
||||
@ -601,7 +627,10 @@
|
||||
"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": "機門已開啟",
|
||||
@ -749,14 +778,21 @@
|
||||
"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.": "無法儲存權限設定。",
|
||||
@ -868,6 +904,7 @@
|
||||
"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": "強制更新",
|
||||
@ -889,6 +926,7 @@
|
||||
"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": "返回",
|
||||
@ -927,10 +965,13 @@
|
||||
"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": "匯入失敗",
|
||||
@ -951,8 +992,12 @@
|
||||
"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": "庫存概覽",
|
||||
@ -971,8 +1016,13 @@
|
||||
"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": "街口支付",
|
||||
@ -1008,6 +1058,7 @@
|
||||
"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": "留空則繼承公司全域設定",
|
||||
@ -1031,15 +1082,18 @@
|
||||
"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": "記錄時間",
|
||||
@ -1077,6 +1131,7 @@
|
||||
"Machine Flavor": "機台風味",
|
||||
"Machine Flow ID": "機台流水號",
|
||||
"Machine Flow ID / Time": "機台流水號 / 時間",
|
||||
"Machine Force Reboot": "強制重啟 APP",
|
||||
"Machine Images": "機台照片",
|
||||
"Machine Inbound Confirmation": "機台入庫確認",
|
||||
"Machine Info": "機台資訊",
|
||||
@ -1092,6 +1147,8 @@
|
||||
"Machine Model Settings": "機台型號設定",
|
||||
"Machine Name": "機台名稱",
|
||||
"Machine Permissions": "機台權限",
|
||||
"Machine Price": "機台價",
|
||||
"Machine Pricing": "機台定價",
|
||||
"Machine Reboot": "機台 APP 重啟",
|
||||
"Machine Registry": "機台清冊",
|
||||
"Machine Replenishment": "機台補貨單",
|
||||
@ -1110,6 +1167,7 @@
|
||||
"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.": "機台已重新連線至伺服器並恢復正常心跳。",
|
||||
@ -1122,6 +1180,8 @@
|
||||
"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": "機台對倉庫",
|
||||
@ -1165,9 +1225,13 @@
|
||||
"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": "物料代碼",
|
||||
@ -1185,6 +1249,7 @@
|
||||
"Max Capacity:": "最大容量:",
|
||||
"Max Stock": "庫存上限",
|
||||
"Maximum file size: 100MB": "檔案大小上限:100MB",
|
||||
"Medicine": "藥品",
|
||||
"Member": "會員價",
|
||||
"Member & External": "會員與外部系統",
|
||||
"Member + 1": "會員 + 1",
|
||||
@ -1238,6 +1303,7 @@
|
||||
"Missing RelateNumber, cannot query": "缺少 RelateNumber,無法查詢",
|
||||
"Mobile Pay": "手機支付",
|
||||
"Mobile Payment": "手機支付",
|
||||
"Mobile barcode carrier": "手機條碼載具",
|
||||
"Model": "機台型號",
|
||||
"Model Default": "型號預設",
|
||||
"Model Name": "型號名稱",
|
||||
@ -1310,6 +1376,7 @@
|
||||
"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": "暫無相關日誌",
|
||||
@ -1317,24 +1384,29 @@
|
||||
"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": "尚無備註",
|
||||
@ -1365,6 +1437,7 @@
|
||||
"Not Used": "不使用",
|
||||
"Not Used Description": "不使用第三方支付介接",
|
||||
"Not a co-branded card": "非聯名卡",
|
||||
"Not stocked on this machine": "未在此機台上架",
|
||||
"Note": "備註",
|
||||
"Note (optional)": "備註 (選填)",
|
||||
"Notes": "備註",
|
||||
@ -1396,6 +1469,7 @@
|
||||
"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.": "帳號一旦刪除,所有關連數據將被永久移除。請輸入您的密碼以確認您希望永久刪除此帳號。",
|
||||
@ -1414,6 +1488,7 @@
|
||||
"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": "操作紀錄",
|
||||
@ -1495,6 +1570,7 @@
|
||||
"Page 69": "取消購買",
|
||||
"Page 7": "鎖定頁",
|
||||
"Page Lock Status": "頁面鎖定狀態",
|
||||
"Paid": "已付款",
|
||||
"Parameters": "參數設定",
|
||||
"Partial Dispense Success": "部分出貨成功",
|
||||
"Pass Code": "通行碼",
|
||||
@ -1515,6 +1591,7 @@
|
||||
"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.": "金流設定已成功刪除。",
|
||||
@ -1535,13 +1612,25 @@
|
||||
"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": "取貨模組",
|
||||
@ -1584,8 +1673,10 @@
|
||||
"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",
|
||||
@ -1604,8 +1695,10 @@
|
||||
"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",
|
||||
@ -1713,6 +1806,7 @@
|
||||
"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": "立即更新",
|
||||
@ -1727,7 +1821,7 @@
|
||||
"Remote Change": "遠端找零",
|
||||
"Remote Checkout": "遠端結帳",
|
||||
"Remote Command Center": "遠端指令中心",
|
||||
"Remote Dispense": "遠端出貨",
|
||||
"Remote Dispense": "遠端開櫃",
|
||||
"Remote Lock": "遠端鎖定",
|
||||
"Remote Management": "遠端管理",
|
||||
"Remote Permissions": "遠端管理權限",
|
||||
@ -1792,7 +1886,9 @@
|
||||
"Running Status": "運行狀態",
|
||||
"SYSTEM": "系統層級",
|
||||
"Safety Stock": "安全庫存",
|
||||
"Sale Paused": "暫停販售",
|
||||
"Sale Price": "售價",
|
||||
"Sale Status": "銷售狀態",
|
||||
"Sales": "銷售管理",
|
||||
"Sales Activity": "銷售活動",
|
||||
"Sales Amount": "銷售金額",
|
||||
@ -1804,6 +1900,7 @@
|
||||
"Sales Today": "今日銷售",
|
||||
"Sales revenue minus product cost net value": "銷售額扣除商品成本淨值",
|
||||
"Save": "儲存",
|
||||
"Save & Sync": "儲存並同步",
|
||||
"Save Changes": "儲存變更",
|
||||
"Save Config": "儲存配置",
|
||||
"Save Material": "儲存素材",
|
||||
@ -1811,6 +1908,8 @@
|
||||
"Save Settings": "儲存設定",
|
||||
"Save Version": "儲存版本",
|
||||
"Save error:": "儲存錯誤:",
|
||||
"Save failed": "儲存失敗",
|
||||
"Saved": "已儲存",
|
||||
"Saved.": "已儲存",
|
||||
"Saving...": "儲存中...",
|
||||
"Scale level and access control": "層級與存取控制",
|
||||
@ -1820,6 +1919,7 @@
|
||||
"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 即可快速進入此設備的維修單填寫頁面。",
|
||||
@ -1897,6 +1997,8 @@
|
||||
"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": "選擇要在此機台播放的素材",
|
||||
@ -1906,6 +2008,8 @@
|
||||
"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": "已選擇",
|
||||
@ -1973,6 +2077,7 @@
|
||||
"Source Machine": "來源機台",
|
||||
"Source Warehouse": "來源倉庫",
|
||||
"Source of temperature limit settings": "溫度限制設定來源",
|
||||
"Spec": "規格",
|
||||
"Special Permission": "特殊權限",
|
||||
"Specification": "規格",
|
||||
"Specifications": "規格",
|
||||
@ -2041,6 +2146,7 @@
|
||||
"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": "下位機狀態",
|
||||
@ -2049,6 +2155,7 @@
|
||||
"Submachine Exception": "下位機硬體異常",
|
||||
"Submachine Exception (B013)": "下位機/貨道異常 (B013)",
|
||||
"Submit Record": "提交紀錄",
|
||||
"Submitting...": "送出中...",
|
||||
"Subtotal": "小計",
|
||||
"Success": "成功",
|
||||
"Super Admin": "超級管理員",
|
||||
@ -2102,6 +2209,7 @@
|
||||
"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": "告警溫度範圍",
|
||||
@ -2127,18 +2235,24 @@
|
||||
"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.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",
|
||||
@ -2390,9 +2504,12 @@
|
||||
"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",
|
||||
@ -2422,6 +2539,7 @@
|
||||
"command.lock": "鎖機",
|
||||
"command.reboot": "重啟",
|
||||
"command.reboot_card": "讀卡機重啟",
|
||||
"command.reboot_force": "強制重啟",
|
||||
"command.reload_stock": "同步庫存",
|
||||
"command.unlock": "解鎖",
|
||||
"command.update_ads": "同步廣告",
|
||||
@ -2453,6 +2571,7 @@
|
||||
"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": "例如:公司標準支付",
|
||||
@ -2528,6 +2647,7 @@
|
||||
"menu.sales": "銷售管理",
|
||||
"menu.sales.orders": "購買單",
|
||||
"menu.sales.pass-codes": "通行碼",
|
||||
"menu.sales.pharmacy-pickup": "領藥單",
|
||||
"menu.sales.pickup-codes": "取貨碼",
|
||||
"menu.sales.promotions": "促銷時段",
|
||||
"menu.sales.records": "銷售紀錄",
|
||||
@ -2558,12 +2678,15 @@
|
||||
"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": "商品目錄已同步至所有機台",
|
||||
@ -2573,6 +2696,7 @@
|
||||
"product_updated": "商品資訊已更新",
|
||||
"remote": "遠端管理",
|
||||
"reservation": "預約系統",
|
||||
"reserved": "預留",
|
||||
"roles": "角色權限",
|
||||
"s": "秒",
|
||||
"sales": "銷售管理",
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "star-cloud",
|
||||
"name": "html",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@ -875,7 +875,6 @@
|
||||
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Fuzzyma"
|
||||
@ -901,7 +900,6 @@
|
||||
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18"
|
||||
},
|
||||
@ -1138,7 +1136,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@ -1804,7 +1801,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@ -2109,7 +2105,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -2532,7 +2527,6 @@
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@ -2629,7 +2623,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -2717,7 +2710,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@ -75,6 +75,11 @@
|
||||
}));
|
||||
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">
|
||||
|
||||
@ -204,6 +204,12 @@
|
||||
pickup_code_enabled: settings.pickup_code_enabled === true || settings.pickup_code_enabled === 1 || settings.pickup_code_enabled === '1',
|
||||
pass_code_enabled: settings.pass_code_enabled === true || settings.pass_code_enabled === 1 || settings.pass_code_enabled === '1',
|
||||
|
||||
// 領藥單(取物單模式下的開關)
|
||||
pharmacy_pickup_enabled: settings.pharmacy_pickup_enabled === true || settings.pharmacy_pickup_enabled === 1 || settings.pharmacy_pickup_enabled === '1',
|
||||
|
||||
// 副櫃系統(格子櫃功能授權,基礎版專用)
|
||||
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',
|
||||
@ -619,6 +625,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 取物單模式專屬:領藥單開關 (toggle switch,比照會員系統/稅務系統) -->
|
||||
<div x-show="machineSettings.shopping_mode === 'pickup_sheet'" x-transition class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 border-l-4 border-l-emerald-500 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
|
||||
<div class="md:col-span-1">
|
||||
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Pharmacy Pickup (Rx)') }}</h4>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ __('Issue pharmacy pickup orders from the backend; patient scans QR to dispense.') }}</p>
|
||||
</div>
|
||||
<div class="md:col-span-3 flex flex-wrap gap-x-8 gap-y-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer group">
|
||||
<div class="relative inline-flex items-center">
|
||||
<input type="hidden" name="settings[pharmacy_pickup_enabled]" value="0">
|
||||
<input type="checkbox" name="settings[pharmacy_pickup_enabled]" value="1"
|
||||
x-model="machineSettings.pharmacy_pickup_enabled" class="peer sr-only">
|
||||
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 group-hover:text-slate-900 dark:group-hover:text-white transition-colors">{{ __('Pharmacy Pickup (Rx)') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 零售/金流展開部分 (僅基礎版顯示) -->
|
||||
<div x-show="machineSettings.shopping_mode === 'basic'" x-transition class="space-y-3 rounded-2xl border border-slate-200 dark:border-slate-700/60 border-l-4 border-l-cyan-500 bg-slate-50/40 dark:bg-slate-800/20 p-4">
|
||||
|
||||
@ -933,6 +958,24 @@
|
||||
</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">
|
||||
|
||||
@ -99,6 +99,9 @@
|
||||
$activeFeatures[] = __('Staff Card');
|
||||
} elseif ($shoppingMode === 'pickup_sheet') {
|
||||
$activeFeatures[] = __('Pickup Sheet (Material No.)');
|
||||
if ($machine->settings['pharmacy_pickup_enabled'] ?? false) {
|
||||
$activeFeatures[] = __('Pharmacy Pickup (Rx)');
|
||||
}
|
||||
} else {
|
||||
$activeFeatures[] = __('Basic Mode');
|
||||
|
||||
@ -172,6 +175,7 @@
|
||||
|
||||
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');
|
||||
@ -242,6 +246,9 @@
|
||||
$activeFeatures[] = __('Staff Card');
|
||||
} elseif ($shoppingMode === 'pickup_sheet') {
|
||||
$activeFeatures[] = __('Pickup Sheet (Material No.)');
|
||||
if ($machine->settings['pharmacy_pickup_enabled'] ?? false) {
|
||||
$activeFeatures[] = __('Pharmacy Pickup (Rx)');
|
||||
}
|
||||
} else {
|
||||
$activeFeatures[] = __('Basic Mode');
|
||||
|
||||
@ -315,6 +322,7 @@
|
||||
|
||||
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');
|
||||
|
||||
@ -444,11 +444,19 @@
|
||||
}
|
||||
this.isStaffModalOpen = true;
|
||||
|
||||
// Re-init Preline select or set value after modal opens
|
||||
// 回填所屬公司下拉:
|
||||
// 1. Preline v3 getInstance(target, true) 回傳的是 collection wrapper 而非實例,
|
||||
// 其上沒有 setValue 會丟錯導致回填失靈,故不可帶第二個參數。
|
||||
// 2. Preline 的 option value 為字串,company_id 為數字時嚴格比對會失配,統一轉字串;空值用 ' ' 對應 placeholder。
|
||||
// 同步先設原生 <select>.value 再呼叫 setValue(對齊 welcome-gifts / pickup-codes 既有可運作寫法)。
|
||||
this.$nextTick(() => {
|
||||
const select = HSSelect.getInstance('#modal-company-id', true);
|
||||
if (select) {
|
||||
select.setValue(this.staffFormFields.company_id || ' ');
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
inventoryLoading: false,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
selectedLevel: '',
|
||||
tab: 'list',
|
||||
viewMode: 'fleet',
|
||||
selectedMachine: null,
|
||||
@ -321,6 +322,7 @@
|
||||
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();
|
||||
@ -720,6 +722,15 @@
|
||||
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>
|
||||
@ -821,6 +832,39 @@
|
||||
<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">
|
||||
@ -854,6 +898,14 @@
|
||||
</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
|
||||
@ -943,7 +995,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:items-center gap-3 sm:gap-4 flex-1">
|
||||
<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="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">{{
|
||||
@ -992,10 +1044,22 @@
|
||||
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">
|
||||
<div class="flex flex-wrap items-center justify-start sm:justify-end gap-4 sm:gap-6 shrink-0">
|
||||
<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">
|
||||
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">
|
||||
<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"
|
||||
@ -1003,8 +1067,10 @@
|
||||
</svg>
|
||||
{{ __('Clear Abnormal Status') }}
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
<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"
|
||||
|
||||
226
resources/views/admin/machines/pricing.blade.php
Normal file
226
resources/views/admin/machines/pricing.blade.php
Normal file
@ -0,0 +1,226 @@
|
||||
@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
|
||||
@ -36,13 +36,6 @@ 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">
|
||||
|
||||
@ -62,6 +62,41 @@ $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">
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
'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'),
|
||||
@ -444,6 +445,7 @@
|
||||
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'],
|
||||
@ -661,6 +663,27 @@
|
||||
</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">
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
<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'),
|
||||
|
||||
@ -251,6 +251,42 @@
|
||||
}
|
||||
},
|
||||
|
||||
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) {
|
||||
@ -296,6 +332,7 @@
|
||||
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';
|
||||
}
|
||||
@ -304,6 +341,7 @@
|
||||
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')) }},
|
||||
@ -321,6 +359,7 @@
|
||||
'sent': {{ Js::from(__('Sent')) }},
|
||||
'success': {{ Js::from(__('Success')) }},
|
||||
'failed': {{ Js::from(__('Failed')) }},
|
||||
'timeout': {{ Js::from(__('Timeout')) }},
|
||||
'superseded': {{ Js::from(__('Superseded')) }}
|
||||
};
|
||||
return statuses[status] || status;
|
||||
@ -349,6 +388,14 @@
|
||||
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 '';
|
||||
@ -663,12 +710,21 @@
|
||||
class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white"
|
||||
x-text="slot.slot_no"></span>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
<!-- Product Image -->
|
||||
@ -774,8 +830,16 @@
|
||||
</template>
|
||||
</div>
|
||||
<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 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>
|
||||
<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>
|
||||
@ -840,7 +904,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -963,6 +1032,24 @@
|
||||
</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 -->
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-2 pb-20" x-data="salesCenter()"
|
||||
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
|
||||
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)"
|
||||
@manual-order-created.window="switchTab('orders'); fetchTabData('orders')">
|
||||
|
||||
{{-- Page Header --}}
|
||||
<x-page-header :title="$title" :subtitle="$description" />
|
||||
@ -107,6 +108,11 @@
|
||||
<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
|
||||
|
||||
@ -227,6 +233,24 @@ 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>`;
|
||||
@ -374,5 +398,230 @@ 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
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
@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
|
||||
@ -0,0 +1,223 @@
|
||||
@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>
|
||||
@ -34,6 +34,7 @@
|
||||
'pending' => 'pending',
|
||||
'paid' => 'active',
|
||||
'completed' => 'active',
|
||||
'awaiting_pickup' => 'pending',
|
||||
'failed' => 'error',
|
||||
'abandoned' => 'disabled',
|
||||
'cancelled' => 'disabled',
|
||||
@ -43,6 +44,7 @@
|
||||
'pending' => __('Pending'),
|
||||
'paid' => __('Paid'),
|
||||
'completed' => __('Completed'),
|
||||
'awaiting_pickup' => __('Awaiting Pickup'),
|
||||
'failed' => __('Failed'),
|
||||
'abandoned' => __('Unpaid'),
|
||||
'cancelled' => __('Cancelled'),
|
||||
@ -102,7 +104,8 @@
|
||||
</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>
|
||||
<p class="text-xs font-black text-slate-400 dark:text-slate-500 font-mono uppercase tracking-widest">{{ $order->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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -232,6 +235,10 @@
|
||||
<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">
|
||||
|
||||
@ -29,6 +29,34 @@
|
||||
</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 }"
|
||||
@ -76,6 +104,18 @@
|
||||
</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') }}">
|
||||
@ -175,6 +215,10 @@
|
||||
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') }}
|
||||
@ -198,9 +242,14 @@
|
||||
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>
|
||||
<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 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>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
@ -243,6 +292,18 @@
|
||||
{{ __('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">
|
||||
@ -267,6 +328,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'],
|
||||
'cancelled' => ['label' => __('Cancelled'), 'color' => 'slate'],
|
||||
@ -292,6 +355,36 @@
|
||||
<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
|
||||
$invoiceSearchValue = $order->invoice ? $order->invoice->invoice_no : $order->order_no;
|
||||
@ -336,8 +429,8 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" class="py-20 text-center">
|
||||
<x-empty-state mode="table" colspan="9" :message="__('No transaction orders found')" />
|
||||
<td colspan="10" class="py-20 text-center">
|
||||
<x-empty-state mode="table" colspan="10" :message="__('No transaction orders found')" />
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
@ -379,6 +472,9 @@
|
||||
<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">
|
||||
@ -416,6 +512,11 @@
|
||||
<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">
|
||||
@ -471,6 +572,37 @@
|
||||
<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>
|
||||
|
||||
{{-- Remark --}}
|
||||
|
||||
@ -12,6 +12,9 @@
|
||||
passCodeName: '',
|
||||
expiresDays: 7,
|
||||
customCode: '',
|
||||
quantity: 1,
|
||||
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
|
||||
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
|
||||
status: '{{ request('status', '') }}',
|
||||
showQrModal: false,
|
||||
activeQrCode: '',
|
||||
@ -145,7 +148,7 @@
|
||||
window.showToast('{{ __('Please enter a description') }}', 'error');
|
||||
return;
|
||||
}
|
||||
if (!this.customCode.trim()) {
|
||||
if (parseInt(this.quantity) <= 1 && !this.customCode.trim()) {
|
||||
window.showToast('{{ __('Please enter or generate a pass code') }}', 'error');
|
||||
return;
|
||||
}
|
||||
@ -194,12 +197,15 @@
|
||||
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')">
|
||||
@ -302,6 +308,42 @@
|
||||
</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">
|
||||
@ -317,8 +359,10 @@
|
||||
</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" required>
|
||||
maxlength="12">
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -377,6 +421,13 @@
|
||||
</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') }}
|
||||
|
||||
@ -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
|
||||
|
||||
459
resources/views/admin/sales/pharmacy-pickup/index.blade.php
Normal file
459
resources/views/admin/sales/pharmacy-pickup/index.blade.php
Normal file
@ -0,0 +1,459 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$statusLabels = [
|
||||
'awaiting_pickup' => __('Awaiting Pickup'),
|
||||
'completed' => __('Picked Up'),
|
||||
'failed' => __('Voided'),
|
||||
'pending' => __('Pending'),
|
||||
'abandoned' => __('Abandoned'),
|
||||
];
|
||||
$statusColors = [
|
||||
'awaiting_pickup' => 'bg-amber-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',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4 pb-20" x-data="pharmacyPickupManager()">
|
||||
|
||||
{{-- 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>
|
||||
|
||||
{{-- 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>
|
||||
@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>
|
||||
@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">
|
||||
@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="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
|
||||
|
||||
{{-- 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
|
||||
@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
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
@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>
|
||||
@endif
|
||||
@if($order->status === 'awaiting_pickup')
|
||||
<form id="pp-cancel-desktop-{{ $order->id }}" method="POST" action="{{ route('admin.sales.pharmacy-pickup.cancel', $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>
|
||||
</form>
|
||||
@endif
|
||||
@if($order->status !== 'awaiting_pickup' && !($order->status !== 'failed' && $order->pickupCode))
|
||||
<span class="text-slate-300 dark:text-slate-600 pr-2">-</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<x-empty-state mode="table" :colspan="7" :message="__('No pharmacy pickup orders yet.')" />
|
||||
@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>
|
||||
|
||||
{{-- 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
|
||||
79
resources/views/admin/sales/pharmacy-pickup/print.blade.php
Normal file
79
resources/views/admin/sales/pharmacy-pickup/print.blade.php
Normal file
@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('menu.sales.pharmacy-pickup') }} {{ $order->order_no }}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "Microsoft JhengHei", "PingFang TC", "Noto Sans TC", sans-serif; margin: 0; background: #f3f4f6; color: #111827; }
|
||||
.actions { text-align: center; margin: 16px auto; }
|
||||
.btn { display: inline-block; padding: 9px 22px; background: #06b6d4; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 700; cursor: pointer; text-decoration: none; }
|
||||
.ticket { width: 360px; margin: 12px auto 28px; background: #fff; padding: 22px 24px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||
.head { text-align: center; border-bottom: 2px dashed #9ca3af; padding-bottom: 12px; margin-bottom: 12px; }
|
||||
.head h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: 4px; }
|
||||
.head .sub { font-size: 12px; color: #6b7280; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; margin: 4px 0; }
|
||||
.row .k { color: #6b7280; white-space: nowrap; }
|
||||
.row .v { font-weight: 600; text-align: right; word-break: break-all; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 12px 0 6px; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #f0f0f0; }
|
||||
th { color: #6b7280; font-weight: 700; border-bottom: 1px solid #d1d5db; }
|
||||
th.qty, td.qty { text-align: center; width: 56px; }
|
||||
.code-box { text-align: center; margin: 16px 0 4px; }
|
||||
.code-box .label { font-size: 12px; color: #6b7280; }
|
||||
.code-box .code { font-size: 32px; font-weight: 800; letter-spacing: 8px; font-family: "Courier New", monospace; }
|
||||
.qr { text-align: center; margin: 8px 0; }
|
||||
.qr svg { width: 200px; height: 200px; }
|
||||
.note { font-size: 12px; color: #4b5563; text-align: center; margin-top: 12px; border-top: 2px dashed #9ca3af; padding-top: 12px; line-height: 1.6; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.no-print { display: none !important; }
|
||||
.ticket { border: none; margin: 0 auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="actions no-print">
|
||||
<button class="btn" onclick="window.print()">{{ __('Print') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="ticket">
|
||||
<div class="head">
|
||||
<h1>{{ __('menu.sales.pharmacy-pickup') }}</h1>
|
||||
<div class="sub">{{ $order->machine?->name }}@if($order->machine?->location) · {{ $order->machine->location }}@endif</div>
|
||||
</div>
|
||||
|
||||
<div class="row"><span class="k">{{ __('Order No.') }}</span><span class="v">{{ $order->order_no }}</span></div>
|
||||
@if($order->pricing_slip_no)
|
||||
<div class="row"><span class="k">{{ __('Pricing Slip No.') }}</span><span class="v">{{ $order->pricing_slip_no }}</span></div>
|
||||
@endif
|
||||
<div class="row"><span class="k">{{ __('Created') }}</span><span class="v">{{ $order->created_at?->format('Y-m-d H:i') }}</span></div>
|
||||
@if($order->pickupCode?->expires_at)
|
||||
<div class="row"><span class="k">{{ __('Valid Until') }}</span><span class="v">{{ $order->pickupCode->expires_at->format('Y-m-d H:i') }}</span></div>
|
||||
@endif
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>{{ __('Medicine') }}</th><th class="qty">{{ __('Quantity') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($order->items as $it)
|
||||
<tr><td>{{ $it->product_name }}</td><td class="qty">{{ $it->quantity }}</td></tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="code-box">
|
||||
<div class="label">{{ __('Pickup Code') }}</div>
|
||||
<div class="code">{{ $order->pickupCode?->code ?? '--------' }}</div>
|
||||
</div>
|
||||
|
||||
@if($qrSvg)
|
||||
<div class="qr">{!! $qrSvg !!}</div>
|
||||
@endif
|
||||
|
||||
<div class="note">{{ __('Scan the QR code or enter the pickup code at the machine to collect your medicine.') }}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
@php
|
||||
$slotSelectConfig = [
|
||||
"placeholder" => __("Select Slot"),
|
||||
"placeholder" => __("Select Product"),
|
||||
"hasSearch" => true,
|
||||
"searchPlaceholder" => __("Search Slot..."),
|
||||
"searchPlaceholder" => __("Search Product..."),
|
||||
"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; fetchSlots()">
|
||||
@change="selectedMachine = $event.target.value; fetchCompanyProducts()">
|
||||
@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 Slot') }} <span class="text-rose-500">*</span></label>
|
||||
__('Select Product') }} <span class="text-rose-500">*</span></label>
|
||||
<div class="relative">
|
||||
<div id="slot-select-wrapper" class="relative">
|
||||
{{-- Rebuilt by Alpine --}}
|
||||
@ -253,6 +253,10 @@ 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">
|
||||
@ -291,6 +295,41 @@ 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">
|
||||
@ -307,8 +346,10 @@ 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" required>
|
||||
maxlength="12">
|
||||
</div>
|
||||
|
||||
{{-- Validity Period Section (Flexible) --}}
|
||||
@ -494,16 +535,21 @@ transition-all duration-300",
|
||||
tabLoading: null,
|
||||
showCreateModal: false,
|
||||
selectedMachine: '',
|
||||
selectedSlot: '',
|
||||
selectedProduct: '',
|
||||
expiresHours: 24,
|
||||
maxHours: 168,
|
||||
expiryMode: 'hours',
|
||||
customExpiry: '',
|
||||
usageLimit: 1,
|
||||
quantity: 1,
|
||||
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
|
||||
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
|
||||
showQrModal: false,
|
||||
activeQrCode: '',
|
||||
activeTicketUrl: '',
|
||||
slots: [],
|
||||
companyProducts: [],
|
||||
machineProductIds: [],
|
||||
productNotOnMachine: false,
|
||||
tabLoading: null,
|
||||
loadingSlots: false,
|
||||
showCancelModal: false,
|
||||
@ -599,18 +645,22 @@ transition-all duration-300",
|
||||
return this.calculateExpiry();
|
||||
},
|
||||
|
||||
async fetchSlots() {
|
||||
// 取貨碼綁商品:下拉以「機台所屬公司的商品」為準(非僅機台貨道);
|
||||
// machineProductIds 用來標示/提示「此機台目前未上架」的商品。
|
||||
async fetchCompanyProducts() {
|
||||
if (!this.selectedMachine) {
|
||||
this.slots = [];
|
||||
this.companyProducts = [];
|
||||
this.machineProductIds = [];
|
||||
return;
|
||||
}
|
||||
this.loadingSlots = true;
|
||||
try {
|
||||
const response = await fetch(`/admin/machines/${this.selectedMachine}/slots-ajax`);
|
||||
const response = await fetch(`/admin/sales/pickup-codes/company-products/${this.selectedMachine}`);
|
||||
const data = await response.json();
|
||||
this.slots = data.slots || [];
|
||||
this.companyProducts = data.products || [];
|
||||
this.machineProductIds = (data.machine_product_ids || []).map(id => String(id));
|
||||
} catch (error) {
|
||||
console.error('Error fetching slots:', error);
|
||||
console.error('Error fetching company products:', error);
|
||||
} finally {
|
||||
this.loadingSlots = false;
|
||||
}
|
||||
@ -621,8 +671,8 @@ transition-all duration-300",
|
||||
window.showToast('{{ __("Please select a machine") }}', 'error');
|
||||
return;
|
||||
}
|
||||
if (!this.selectedSlot) {
|
||||
window.showToast('{{ __("Please select a slot") }}', 'error');
|
||||
if (!this.selectedProduct) {
|
||||
window.showToast('{{ __("Please select a product") }}', 'error');
|
||||
return;
|
||||
}
|
||||
this.$refs.createForm.submit();
|
||||
@ -674,6 +724,8 @@ transition-all duration-300",
|
||||
this.showCancelModal = false;
|
||||
},
|
||||
|
||||
// 取貨碼改綁商品:下拉改列「此機台有上架的商品」(由貨道清單去重),
|
||||
// 提交 product_id;貨道改由 App 刷碼當下依即時庫存挑選。
|
||||
updateSlotSelect() {
|
||||
this.$nextTick(() => {
|
||||
const wrapper = document.getElementById('slot-select-wrapper');
|
||||
@ -687,15 +739,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 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>`;
|
||||
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>`;
|
||||
wrapper.appendChild(dummy);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.name = 'slot_no';
|
||||
selectEl.name = 'product_id';
|
||||
// Remove required to allow our custom validation toast to trigger
|
||||
selectEl.id = 'modal-pickup-slot-' + Date.now();
|
||||
selectEl.id = 'modal-pickup-product-' + Date.now();
|
||||
selectEl.className = 'hidden';
|
||||
|
||||
// 清理配置字串中的換行符
|
||||
@ -706,22 +758,32 @@ transition-all duration-300",
|
||||
|
||||
const placeholderOpt = document.createElement('option');
|
||||
placeholderOpt.value = "";
|
||||
placeholderOpt.textContent = "{{ __('Select Slot') }}";
|
||||
placeholderOpt.setAttribute('data-title', "{{ __('Select Slot') }}");
|
||||
placeholderOpt.textContent = "{{ __('Select Product') }}";
|
||||
placeholderOpt.setAttribute('data-title', "{{ __('Select Product') }}");
|
||||
selectEl.appendChild(placeholderOpt);
|
||||
|
||||
this.slots.forEach(slot => {
|
||||
// 列出機台所屬公司的商品;機台目前未上架者於名稱後標示提示
|
||||
this.companyProducts.forEach(product => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = slot.slot_no;
|
||||
const text = `${slot.slot_no} - ${slot.product ? slot.product.name : 'Empty'}`;
|
||||
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.textContent = text;
|
||||
opt.setAttribute('data-title', text);
|
||||
if (this.selectedSlot == slot.slot_no) opt.selected = true;
|
||||
if (this.selectedProduct == product.id) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
|
||||
wrapper.appendChild(selectEl);
|
||||
selectEl.addEventListener('change', e => { this.selectedSlot = e.target.value; });
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
if (window.HSStaticMethods?.autoInit) {
|
||||
window.HSStaticMethods.autoInit(['select']);
|
||||
@ -757,13 +819,14 @@ transition-all duration-300",
|
||||
});
|
||||
});
|
||||
|
||||
this.$watch('slots', () => this.updateSlotSelect());
|
||||
this.$watch('companyProducts', () => this.updateSlotSelect());
|
||||
this.$watch('selectedMachine', (val) => {
|
||||
this.syncSelect('modal-pickup-machine', val);
|
||||
this.selectedSlot = ''; // Reset slot when machine changes
|
||||
this.fetchSlots();
|
||||
this.selectedProduct = ''; // Reset product when machine changes
|
||||
this.productNotOnMachine = false;
|
||||
this.fetchCompanyProducts();
|
||||
});
|
||||
this.$watch('selectedSlot', (val) => {
|
||||
this.$watch('selectedProduct', (val) => {
|
||||
// Selection toast removed per user request
|
||||
});
|
||||
this.$watch('expiryMode', (mode) => {
|
||||
@ -775,15 +838,18 @@ transition-all duration-300",
|
||||
this.$watch('showCreateModal', (val) => {
|
||||
if (val) {
|
||||
this.selectedMachine = '';
|
||||
this.selectedSlot = '';
|
||||
this.selectedProduct = '';
|
||||
this.productNotOnMachine = false;
|
||||
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.slots = [];
|
||||
this.companyProducts = [];
|
||||
this.machineProductIds = [];
|
||||
this.generateRandomCode();
|
||||
this.updateSlotSelect();
|
||||
}
|
||||
|
||||
@ -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 / Slot') }}
|
||||
{{ __('Product') }}
|
||||
</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,23 +117,33 @@
|
||||
<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
|
||||
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
|
||||
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
|
||||
// 綁商品碼(新):直接取 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;
|
||||
}
|
||||
@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')
|
||||
}}: {{ $code->slot_no }}</div>
|
||||
}}: {{ $slotLabel }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-6 whitespace-nowrap">
|
||||
<div class="text-sm font-black text-slate-600 dark:text-slate-300">
|
||||
@ -251,7 +261,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>
|
||||
@ -281,19 +291,24 @@
|
||||
<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">
|
||||
{{ __('Slot') }}</p>
|
||||
{{ __('Product') }}</p>
|
||||
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
|
||||
{{ $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>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -85,6 +85,13 @@
|
||||
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>
|
||||
@ -96,6 +103,60 @@
|
||||
</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">
|
||||
@ -260,6 +321,32 @@
|
||||
</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">
|
||||
@ -296,6 +383,12 @@
|
||||
<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
|
||||
@ -364,8 +457,16 @@
|
||||
</template>
|
||||
</div>
|
||||
<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 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="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>
|
||||
@ -435,6 +536,12 @@
|
||||
</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>
|
||||
|
||||
@ -783,6 +890,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@ -797,6 +905,35 @@ 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');
|
||||
|
||||
@ -104,6 +104,7 @@
|
||||
'logs' => __('Machine Logs'),
|
||||
'permissions' => __('Machine Permissions'),
|
||||
'utilization' => __('Utilization Rate'),
|
||||
'pricing' => __('Machine Pricing'),
|
||||
'expiry' => __('Expiry Management'),
|
||||
'maintenance' => __('Maintenance Records'),
|
||||
'ui-elements' => __('UI Elements'),
|
||||
@ -120,6 +121,7 @@
|
||||
'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'),
|
||||
|
||||
@ -15,8 +15,12 @@
|
||||
</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
|
||||
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
|
||||
$product = $slot?->product;
|
||||
// 取貨碼可能綁「商品」(product_id,slot_no 為 null,貨道刷碼當下挑) 或綁「貨道」(舊版)。
|
||||
// 優先用直接綁定的商品;否則回退用 slot_no 對應貨道的商品。
|
||||
$slot = $pickupCode->slot_no
|
||||
? $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first()
|
||||
: null;
|
||||
$product = $pickupCode->product ?? $slot?->product;
|
||||
@endphp
|
||||
|
||||
<div class="w-full max-w-sm animate-luxury-in">
|
||||
@ -53,7 +57,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">
|
||||
|
||||
@ -216,6 +216,12 @@
|
||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Store Gifts') }}</span>
|
||||
</a></li>
|
||||
@endcan
|
||||
@can('menu.sales.pharmacy-pickup')
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.sales.pharmacy-pickup') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.sales.pharmacy-pickup') }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v11a2 2 0 002 2z" /></svg>
|
||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('menu.sales.pharmacy-pickup') }}</span>
|
||||
</a></li>
|
||||
@endcan
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@ -68,6 +68,10 @@ 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');
|
||||
@ -119,7 +123,9 @@ 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:機台補貨
|
||||
@ -142,6 +148,10 @@ 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');
|
||||
@ -150,19 +160,24 @@ 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');
|
||||
@ -170,7 +185,14 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
|
||||
Route::post('/store-gifts', [App\Http\Controllers\Admin\SalesController::class, 'storeWelcomeGift'])->name('store-gifts.store');
|
||||
Route::patch('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'updateWelcomeGift'])->name('store-gifts.update');
|
||||
Route::delete('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'destroyWelcomeGift'])->name('store-gifts.destroy');
|
||||
|
||||
|
||||
// 領藥單(取物單模式 / 領藥模組)— 受權限 menu.sales.pharmacy-pickup 控管
|
||||
Route::get('/pharmacy-pickup', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'index'])->name('pharmacy-pickup')->middleware('can:menu.sales.pharmacy-pickup');
|
||||
Route::post('/pharmacy-pickup', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'store'])->name('pharmacy-pickup.store')->middleware('can:menu.sales.pharmacy-pickup');
|
||||
Route::get('/pharmacy-pickup/{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');
|
||||
|
||||
// 詳情路由必須放在最後,避免攔截其他具體路由
|
||||
Route::get('/{order}', [App\Http\Controllers\Admin\SalesController::class, 'show'])->name('show');
|
||||
});
|
||||
@ -194,6 +216,7 @@ 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');
|
||||
|
||||
157
tests/Feature/Admin/AccountHierarchyTest.php
Normal file
157
tests/Feature/Admin/AccountHierarchyTest.php
Normal file
@ -0,0 +1,157 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
160
tests/Feature/Admin/AccountSubsetGuardTest.php
Normal file
160
tests/Feature/Admin/AccountSubsetGuardTest.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?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、m2(m2 在操作者範圍外)
|
||||
|
||||
// 操作者送出清空([]):應只移除自己範圍內的 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); // 操作者沒被授權的機台不應出現在可授權清單
|
||||
}
|
||||
}
|
||||
117
tests/Feature/Admin/RoleHierarchyTest.php
Normal file
117
tests/Feature/Admin/RoleHierarchyTest.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?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_by;update/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]);
|
||||
}
|
||||
}
|
||||
100
tests/Feature/Admin/SalesMachineAccessTest.php
Normal file
100
tests/Feature/Admin/SalesMachineAccessTest.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
101
tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php
Normal file
101
tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?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)自動清空效期/批號 —— 需 MySQL(recordDispense 內部寫
|
||||
* machine_stock_movements,其 type enum 為 MySQL-only DDL,sqlite :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, '未售完應保留批號');
|
||||
}
|
||||
}
|
||||
@ -101,6 +101,20 @@ class FinalizeIdempotencyTest extends TestCase
|
||||
$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
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user