star-cloud/app/Http/Controllers/Admin/SalesController.php
sky121113 f571f99084 [FEAT] 機台日誌層級篩選與銷售紀錄付款/出貨狀態篩選
1. 機台日誌新增「層級」篩選下拉(所有層級/Info/Warning/Error),採 x-searchable-select 元件,選擇即時重撈,清除篩選一併重置
2. 銷售紀錄訂單分頁新增「付款狀態」篩選(對應 status 欄)
3. 銷售紀錄訂單分頁新增「出貨狀態」篩選(delivery_status),比照列表顯示邏輯限定 completed/paid 訂單,避免未出貨單因預設值誤篩
4. 補齊三語系 key:All Payment Statuses、All Dispense Statuses、Paid,並對齊排序

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:05:45 +08:00

1383 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Transaction\Order;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\PaymentType;
use App\Models\Transaction\OrderItem;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCode;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\Machine\Machine;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use App\Models\System\SystemOperationLog;
class SalesController extends Controller
{
// 銷售中心 (銷售&金流紀錄)
public function index(Request $request)
{
$tab = $request->input('tab', 'orders');
$isAjax = $request->ajax();
// 取得篩選參數
$search = $request->input('search');
$machineId = $request->input('machine_id');
$paymentType = $request->input('payment_type');
$status = $request->input('status');
$deliveryStatus = $request->input('delivery_status');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// 判斷是否為隱含篩選 (未選日期時預設抓6個月)
$isImplicitFilter = false;
if (!$startDate || !$endDate) {
$isImplicitFilter = true;
$startDate = now()->subMonths(6)->format('Y-m-d H:i');
$endDate = now()->format('Y-m-d H:i');
}
$data = [
'title' => '銷售&金流紀錄',
'description' => '銷售交易與金流明細查詢',
'tab' => $tab,
'filters' => [
'search' => $search,
'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'),
],
'isImplicitFilter' => $isImplicitFilter,
'machines' => Machine::select('id', 'name', 'serial_no')->get(),
'paymentTypes' => Order::getPaymentTypeLabels(),
];
// 1. 建立基本查詢 (套用共用過濾器:機台、日期)
$ordersQuery = Order::with([
'machine:id,name,serial_no',
'invoice:id,order_id,invoice_no,status',
'items',
'staffCardLog:id,order_id,staff_card_id',
'staffCardLog.staffCard:id,employee_id,name,card_uid'
]);
$invoicesQuery = Invoice::with(['machine:id,name,serial_no', 'order:id,order_no,flow_id,payment_type']);
$dispenseQuery = DispenseRecord::with(['order:id,order_no,flow_id', 'machine:id,name,serial_no', 'product:id,name']);
// 共用過濾器:日期
$start = Carbon::parse($startDate);
$end = Carbon::parse($endDate)->endOfMinute();
$ordersQuery->whereBetween('created_at', [$start, $end]);
// 以 created_at 篩(而非 invoice_datepending/failed 發票尚無 invoice_date 也能顯示,才看得到缺漏
$invoicesQuery->whereBetween('created_at', [$start, $end]);
$dispenseQuery->whereBetween('machine_time', [$start, $end]);
// 共用過濾器:機台
if ($machineId) {
$ordersQuery->where('machine_id', $machineId);
$invoicesQuery->where('machine_id', $machineId);
$dispenseQuery->where('machine_id', $machineId);
}
// 2. 應用獨立過濾器:搜尋 (僅對當前 Tab 應用)
if ($search) {
if ($tab === 'orders') {
$ordersQuery->where(function($q) use ($search) {
$q->where('order_no', 'like', "%{$search}%")
->orWhere('flow_id', 'like', "%{$search}%")
->orWhere('invoice_info', 'like', "%{$search}%")
->orWhere('member_barcode', 'like', "%{$search}%")
->orWhereHas('staffCardLog.staffCard', function($sq) use ($search) {
$sq->where('name', 'like', "%{$search}%")
->orWhere('employee_id', 'like', "%{$search}%")
->orWhere('card_uid', 'like', "%{$search}%");
});
});
} elseif ($tab === 'invoices') {
$invoicesQuery->where(function($q) use ($search) {
$q->where('invoice_no', 'like', "%{$search}%")
->orWhere('flow_id', 'like', "%{$search}%");
});
} elseif ($tab === 'dispense') {
$dispenseQuery->where(function($q) use ($search) {
$q->where('slot_no', 'like', "%{$search}%")
->orWhereHas('product', function($pq) use ($search) {
$pq->where('name', 'like', "%{$search}%");
})
->orWhereHas('order', function($oq) use ($search) {
$oq->where('order_no', 'like', "%{$search}%");
});
});
}
}
// 訂單專用過濾器
if ($tab === 'orders') {
if ($paymentType) $ordersQuery->where('payment_type', $paymentType);
if ($status) $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
if ($tab === 'invoices') {
$invoiceStatus = $request->input('invoice_status');
if ($invoiceStatus) $invoicesQuery->where('status', $invoiceStatus);
}
// 匯出功能攔截
if ($request->filled('export')) {
$exportType = $request->input('export');
return $this->handleExport($tab, $exportType, $ordersQuery, $invoicesQuery, $dispenseQuery);
}
// 3. 執行分頁 (使用獨立的 pageName)
$perPage = $request->input('per_page', 10);
$data['orders'] = $ordersQuery->latest()->paginate($perPage, ['*'], 'orders_page')->withQueryString();
$data['invoices'] = $invoicesQuery->latest()->paginate($perPage, ['*'], 'invoices_page')->withQueryString();
$data['dispenseLogs'] = $dispenseQuery->latest()->paginate($perPage, ['*'], 'dispense_page')->withQueryString();
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.index', $data);
}
/**
* 取得單筆交易詳情 (用於 Slide-over)
*/
public function show(Order $order)
{
$order->load([
'machine',
'invoice',
'items',
'dispenseRecords.product',
'staffCardLog:id,order_id,staff_card_id',
'staffCardLog.staffCard:id,employee_id,name,card_uid'
]);
return response()->json([
'success' => true,
'html' => view('admin.sales.partials.order-detail-panel', [
'order' => $order,
'paymentTypes' => Order::getPaymentTypeLabels()
])->render()
]);
}
// 取貨碼設定
public function pickupCodes(Request $request)
{
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '取貨碼設定',
'description' => '產生與管理商品取貨驗證碼',
'tab' => $tab,
];
// 1. 取貨碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PickupCode::with(['machine.slots.product', 'creator', 'order'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$query->where('status', trim($request->status));
}
$per_page = $request->input('per_page', 10);
$data['pickupCodes'] = $query->paginate($per_page, ['*'], 'list_page')->withQueryString();
// 供新增彈窗使用的機台清單
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料machine_id 為 null 的非機台紀錄仍保留)
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order'])
->where(function ($q) {
$q->whereNull('machine_id')
->orWhereHas('machine');
});
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('pickupCode', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%");
});
});
}
// 新增:類型篩選
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 新增:日期區間篩選 (隱含 6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'create' => __('create'),
'update' => __('update'),
'cancel' => __('cancel'),
'used' => __('used'),
'consume' => __('consume'),
'consume_failed' => __('consume_failed'),
'verify_success' => __('verify_success'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.pickup-codes.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.pickup-codes.index', $data);
}
/**
* 產生取貨碼
*/
public function storePickupCode(Request $request)
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'slot_no' => 'required|string',
'usage_limit' => 'nullable|integer|min:1|max:20',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 處理過期時間:優先使用直接傳入的日期,否則使用時數計算
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
: now()->addHours((int) ($request->expires_hours ?? 24));
$pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'],
'slot_no' => $validated['slot_no'],
'code' => $request->custom_code ?? PickupCode::generateUniqueCode($validated['machine_id']),
'usage_limit' => $validated['usage_limit'] ?? 1,
'usage_count' => 0,
'expires_at' => $expiresAt,
'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'create',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'new_values' => $pickupCode->toArray(),
]);
return back()->with('success', __('Pickup code generated: :code', ['code' => $pickupCode->code]));
}
/**
* 更新取貨碼 (僅限修改時間)
*/
public function updatePickupCode(Request $request, PickupCode $pickupCode)
{
$validated = $request->validate([
'expires_at' => 'nullable|date|after:now',
'usage_limit' => 'nullable|integer|min:1|max:20',
]);
$oldValues = $pickupCode->toArray();
$pickupCode->update(array_filter([
'expires_at' => $validated['expires_at'] ?? null,
'usage_limit' => $validated['usage_limit'] ?? null,
]));
SystemOperationLog::create([
'company_id' => $pickupCode->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'update',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'old_values' => $oldValues,
'new_values' => $pickupCode->toArray(),
]);
return back()->with('success', __('Pickup code updated.'));
}
/**
* 刪除/取消取貨碼
*/
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']);
SystemOperationLog::create([
'company_id' => $pickupCode->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'cancel',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'old_values' => $oldValues,
'new_values' => $pickupCode->toArray(),
]);
return back()->with('success', __('Pickup code cancelled.'));
}
// 購買單
public function orders()
{
return view('admin.placeholder', [
'title' => '購買單',
'description' => '購買訂單管理',
]);
}
// 促銷時段設定
public function promotions()
{
return view('admin.placeholder', [
'title' => '促銷時段設定',
'description' => '促銷活動時間設定',
]);
}
// 通行碼設定
public function passCodes(Request $request)
{
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '通行碼設定',
'description' => '特殊通行權限碼管理 (測試/補貨用)',
'tab' => $tab,
];
// 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PassCode::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhere('name', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$status = trim($request->status);
if ($status === 'active') {
$query->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
} elseif ($status === 'expired') {
$query->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
} else {
$query->where('status', $status);
}
}
$data['passCodes'] = $query->paginate($request->input('per_page', 10), ['*'], 'list_page')->withQueryString();
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料pass_code_logs.machine_id 為 NOT NULL
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('passCode', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
});
}
// 新增:類型篩選
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 新增:日期區間篩選 (隱含 6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'create' => __('create'),
'update' => __('update'),
'cancel' => __('cancel'),
'used' => __('used'),
'consume' => __('consume'),
'verify_success' => __('verify_success'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.pass-codes.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.pass-codes.index', $data);
}
/**
* 產生通行碼
*/
public function storePassCode(Request $request)
{
$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',
]);
$machine = Machine::findOrFail($validated['machine_id']);
$expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null;
$passCode = PassCode::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'] ?? 'Manual Generate',
'code' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt,
'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'create',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code]));
}
/**
* 更新通行碼
*/
public function updatePassCode(Request $request, PassCode $passCode)
{
$validated = $request->validate([
'name' => 'nullable|string|max:50',
'expires_at' => 'nullable|date',
'status' => 'nullable|in:active,disabled',
]);
// 確保 expires_at 為空字串時轉為 null
if (isset($validated['expires_at']) && empty($validated['expires_at'])) {
$validated['expires_at'] = null;
}
$oldValues = $passCode->toArray();
$passCode->update(array_filter($validated, function ($value, $key) use ($request) {
return $request->has($key);
}, ARRAY_FILTER_USE_BOTH));
SystemOperationLog::create([
'company_id' => $passCode->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'update',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'old_values' => $oldValues,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code updated.'));
}
/**
* 刪除通行碼 (改為停用)
*/
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']);
SystemOperationLog::create([
'company_id' => $passCode->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'cancel',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'old_values' => $oldValues,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code cancelled.'));
}
// 來店禮設定
public function storeGifts(Request $request)
{
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '來店禮設定',
'description' => '來店禮優惠碼管理 (單次與無限制來店優惠折抵)',
'tab' => $tab,
];
// 1. 來店禮列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的來店禮(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = WelcomeGift::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhere('name', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$status = trim($request->status);
if ($status === 'active') {
$query->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
} elseif ($status === 'expired') {
$query->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
} else {
$query->where('status', $status);
}
}
$data['welcomeGifts'] = $query->paginate($request->input('per_page', 10), ['*'], 'list_page')->withQueryString();
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料welcome_gift_logs.machine_id 為 NOT NULL
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('welcomeGift', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
});
}
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 日期區間篩選 (6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'verify_success' => __('verify_success'),
'consume' => __('consume'),
'cancel' => __('cancel'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.welcome-gifts.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.welcome-gifts.index', $data);
}
/**
* 新增來店禮
*/
public function storeWelcomeGift(Request $request)
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:100',
'discount_type' => 'required|in:percentage,amount',
'discount_val_input' => 'required|numeric|min:0.1', // 接受折數 (如 8.5) 或 金額 (如 50)
'usage_type' => 'required|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:10',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 關鍵自動換算邏輯
$discountValue = 0;
if ($validated['discount_type'] === 'percentage') {
// 使用者輸入折數 (如 8 代表打八折8.5 代表打八五折)
$fold = (float) $validated['discount_val_input'];
// 換算為趴數,公式為 (10 - 折數) * 10 ➜ 例如 8.5折 ➜ (10 - 8.5) * 10 = 15% off
$discountValue = (int) round((10 - $fold) * 10);
} else {
// 金額折抵 (如 50 代表折 50 元)
$discountValue = (int) $validated['discount_val_input'];
}
$welcomeGift = WelcomeGift::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'],
'code' => $validated['custom_code'] ?? WelcomeGift::generateUniqueCode($validated['machine_id']),
'discount_type' => $validated['discount_type'],
'discount_value' => $discountValue,
'usage_type' => $validated['usage_type'],
'usage_limit' => $validated['usage_type'] === 'once' ? ($validated['usage_limit'] ?? 1) : null,
'expires_at' => $validated['expires_at'],
'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'create',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift created successfully.'));
}
/**
* 更新來店禮
*/
public function updateWelcomeGift(Request $request, WelcomeGift $welcomeGift)
{
$validated = $request->validate([
'name' => 'nullable|string|max:100',
'discount_type' => 'nullable|in:percentage,amount',
'discount_val_input' => 'nullable|numeric|min:0.1',
'expires_at' => 'nullable|date',
'status' => 'nullable|in:active,disabled',
'usage_type' => 'nullable|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
]);
if (isset($validated['expires_at']) && empty($validated['expires_at'])) {
$validated['expires_at'] = null;
}
$oldValues = $welcomeGift->toArray();
// 整理要更新的資料
$updateData = [];
if ($request->has('name')) {
$updateData['name'] = $validated['name'];
}
if ($request->has('status')) {
$updateData['status'] = $validated['status'];
}
if ($request->has('expires_at')) {
$updateData['expires_at'] = $validated['expires_at'];
}
if ($request->has('usage_type')) {
$updateData['usage_type'] = $validated['usage_type'];
if ($validated['usage_type'] === 'unlimited') {
$updateData['usage_limit'] = null;
} elseif ($request->has('usage_limit')) {
$updateData['usage_limit'] = $validated['usage_limit'];
}
} elseif ($request->has('usage_limit') && $welcomeGift->usage_type === 'once') {
$updateData['usage_limit'] = $validated['usage_limit'];
}
// 折扣折數/金額更新
if ($request->has('discount_type') || $request->has('discount_val_input')) {
$discountType = $validated['discount_type'] ?? $welcomeGift->discount_type;
$discountValInput = $validated['discount_val_input'] ?? ($discountType === 'percentage' ? $welcomeGift->input_fold : $welcomeGift->discount_value);
$updateData['discount_type'] = $discountType;
if ($discountType === 'percentage') {
$fold = (float) $discountValInput;
$updateData['discount_value'] = (int) round((10 - $fold) * 10);
} else {
$updateData['discount_value'] = (int) $discountValInput;
}
}
$welcomeGift->update($updateData);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'update',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift updated.'));
}
/**
* 刪除來店禮 (改為停用)
*/
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']);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'cancel',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift cancelled.'));
}
/**
* 處理銷售中心資料匯出 (CSV 串流下載)
*/
private function handleExport($tab, $type, $ordersQuery, $invoicesQuery, $dispenseQuery)
{
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
$filename = '';
$headers = [];
$callback = null;
if ($tab === 'orders') {
$filename = '交易紀錄_' . now()->format('YmdHis') . '.' . $ext;
$headers = [
__('Order Number'),
__('Transaction Time'),
__('Machine Name'),
__('Machine Serial No'),
__('Product Items'),
__('Quantity'),
__('Original Amount'),
__('Discount Amount'),
__('Payment Amount'),
__('Payment Type'),
__('Payment Status'),
__('Dispense Status'),
__('Invoice Number'),
__('Staff Name'),
__('Staff ID'),
__('Card UID'),
];
// 預先載入關聯以防 N+1
$ordersQuery->with([
'machine:id,name,serial_no',
'invoice:id,order_id,invoice_no',
'items:id,order_id,product_name,quantity',
'staffCardLog:id,order_id,staff_card_id',
'staffCardLog.staffCard:id,employee_id,name,card_uid'
]);
$callback = function () use ($ordersQuery, $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);
}
// 分批 (Chunk) 查詢,每次 500 筆,確保常數記憶體用量
$ordersQuery->latest()->chunk(500, function ($orders) use ($file, $isExcel) {
foreach ($orders as $order) {
$productSummary = $order->items->map(function ($item) {
return $item->product_name . ' x' . number_format($item->quantity, 0);
})->implode(', ');
$totalQty = $order->items->sum('quantity');
// 轉換訂單狀態標籤,與前端 tab-orders.blade.php / detail 面板一致
$statusLabelMap = [
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
$paymentStatusLabel = $statusLabelMap[$order->status] ?? $order->status;
$row = [
$order->order_no,
$order->created_at->format('Y-m-d H:i:s'),
$order->machine->name ?? 'Unknown',
$order->machine->serial_no ?? '---',
$productSummary ?: '---',
$totalQty,
number_format($order->total_amount, 2, '.', ''),
number_format($order->discount_amount, 2, '.', ''),
number_format($order->pay_amount, 2, '.', ''),
$order->payment_type_label,
$paymentStatusLabel,
$order->delivery_status_label,
$order->invoice->invoice_no ?? '---',
$order->staffCardLog->staffCard->name ?? '---',
$order->staffCardLog->staffCard->employee_id ?? '---',
$order->staffCardLog->staffCard->card_uid ?? '---',
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $val) {
fwrite($file, '<td>' . htmlspecialchars($val) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
} elseif ($tab === 'invoices') {
$filename = '電子發票紀錄_' . now()->format('YmdHis') . '.' . $ext;
$headers = [
__('Invoice Number'),
__('Invoice Date'),
__('Flow ID'),
__('Machine Name'),
__('Machine Serial No'),
__('Associated Order'),
__('Amount'),
__('Status'),
];
// 預先載入關聯
$invoicesQuery->with([
'machine:id,name,serial_no',
'order:id,order_no'
]);
$callback = function () use ($invoicesQuery, $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");
fputcsv($file, $headers);
}
$invoicesQuery->latest()->chunk(500, function ($invoices) use ($file, $isExcel) {
foreach ($invoices as $inv) {
// 狀態判斷邏輯需與 tab-invoices.blade.php 完全一致
$status = 'valid';
if (empty($inv->invoice_no)) $status = 'failed';
if ($inv->rtn_code === 'void') $status = 'void';
$statusMap = [
'valid' => __('Valid'),
'void' => __('Void'),
'failed' => __('Failed'),
'refunded' => __('Refunded'),
];
$statusLabel = $statusMap[$status] ?? $status;
$row = [
$inv->invoice_no ?: '---',
$inv->invoice_date ?: '---',
$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 ?? '---',
number_format($inv->amount, 2, '.', ''),
$statusLabel,
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $val) {
fwrite($file, '<td>' . htmlspecialchars($val) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
} elseif ($tab === 'dispense') {
$filename = '遠端出貨紀錄_' . now()->format('YmdHis') . '.' . $ext;
$headers = [
__('Machine'),
__('Machine Serial No'),
__('Product Name'),
__('Slot No'),
__('Amount'),
__('Dispense Status'),
__('Dispense Time'),
__('Associated Order'),
];
// 預先載入關聯
$dispenseQuery->with([
'machine:id,name,serial_no',
'product:id,name',
'order:id,order_no'
]);
$callback = function () use ($dispenseQuery, $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");
fputcsv($file, $headers);
}
$dispenseQuery->latest()->chunk(500, function ($logs) use ($file, $isExcel) {
foreach ($logs as $log) {
// 出貨狀態對照,需與 tab-dispense.blade.php 完全一致
$statusMap = [
'success' => __('Dispense Success'),
'failed' => __('Dispense Failed'),
'pending' => __('Dispense Pending'),
'1' => __('Dispense Success'),
'0' => __('Dispense Failed'),
];
$statusKey = (string)($log->dispense_status ?? 'pending');
$statusLabel = $statusMap[$statusKey] ?? $statusKey;
$row = [
$log->machine->name ?? 'Unknown',
$log->machine->serial_no ?? '---',
$log->product->localized_name ?? __('Unknown Product'),
$log->slot_no,
number_format($log->amount, 2, '.', ''),
$statusLabel,
$log->machine_time ? $log->machine_time->format('Y-m-d H:i:s') : '---',
$log->order->order_no ?? '---',
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $val) {
fwrite($file, '<td>' . htmlspecialchars($val) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
}
if ($callback) {
return response()->streamDownload($callback, $filename, [
'Content-Type' => $contentType,
'Cache-Control' => 'no-cache, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
return redirect()->back();
}
// ===================== 電子發票對帳/補開/作廢 =====================
/**
* 列印發票向綠界取得列印頁網址InvoicePrint後 302 導向。
* 該網址自綠界回應起 1 小時內有效,前端以 target="_blank" 於新分頁開啟。
*/
public function printInvoice(Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 僅已開立且有發票號者可列印
if ($invoice->status !== Invoice::STATUS_ISSUED || empty($invoice->invoice_no)) {
return back()->with('error', __('Only issued invoices can be printed'));
}
// 僅「統編發票」可列印:本系統開立時 Print=1 僅給帶統編者(見 EcpayInvoiceService::issue 的 Print 規則);
// 捐贈、載具、一般無統編 B2C 皆以 Print=0 開立,綠界 InvoicePrint 會回查無資料而失敗。
if (empty($invoice->metadata['business_tax_id'] ?? '')) {
return back()->with('error', __('Only invoices with a tax ID can be printed'));
}
$machine = $invoice->machine;
if (!$machine || !$ecpay->configForMachine($machine)) {
return back()->with('error', __('This machine has no ECPay invoice settings'));
}
$date = $invoice->invoice_date
? Carbon::parse($invoice->invoice_date)->format('Y-m-d')
: '';
$result = $ecpay->invoicePrint($machine, $invoice->invoice_no, $date);
$url = $result['InvoiceHtml'] ?? '';
if ($result === null || (string) ($result['RtnCode'] ?? '') !== '1' || empty($url)) {
return back()->with('error', __('Failed to get invoice print URL'));
}
return redirect()->away($url);
}
/**
* 手動向綠界查詢單張發票真實狀態GetIssue
* pending 掉包時用此確認:已開→補登 issued查無→標 failed 待補開。
*/
public function reconcileInvoice(Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 伺服器端狀態守衛:僅 pending/failed 可查證(防偽造 POST 對終態發票亂操作)
if (!in_array($invoice->status, [Invoice::STATUS_PENDING, Invoice::STATUS_FAILED], true)) {
return back()->with('error', __('Only pending/failed invoices can be queried'));
}
$machine = $invoice->machine;
if (!$machine || !$ecpay->configForMachine($machine)) {
return back()->with('error', __('This machine has no ECPay invoice settings'));
}
if (empty($invoice->relate_number)) {
return back()->with('error', __('Missing RelateNumber, cannot query'));
}
$result = $ecpay->getIssue($machine, $invoice->relate_number);
if ($result === null) {
$invoice->increment('retry_count');
$invoice->update(['last_checked_at' => now()]);
return back()->with('error', __('ECPay query failed'));
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
$invoiceNo = $result['IIS_Number'] ?? '';
if ($rtnCode === '1' && !empty($invoiceNo)) {
$invoice->update([
'status' => Invoice::STATUS_ISSUED,
'invoice_no' => $invoiceNo,
'invoice_date' => $this->safeDate($result['IIS_Create_Date'] ?? null),
'random_number' => $result['IIS_Random_Number'] ?? $invoice->random_number,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? null,
'last_checked_at' => now(),
]);
return back()->with('success', __('Confirmed issued: ') . $invoiceNo);
}
$invoice->update([
'status' => Invoice::STATUS_FAILED,
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'last_checked_at' => now(),
]);
return back()->with('error', __('ECPay has no such invoice; marked as pending re-issue'));
}
/** 補開Issue沿用同一 RelateNumber 冪等。 */
public function reissueInvoice(Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 伺服器端狀態守衛:僅 pending/failed 可補開(防把 issued/void 重開造成重複開立)
if (!in_array($invoice->status, [Invoice::STATUS_PENDING, Invoice::STATUS_FAILED], true)) {
return back()->with('error', __('Only pending/failed invoices can be re-issued'));
}
$result = $ecpay->reissue($invoice);
if ($result === null) {
return back()->with('error', __('Re-issue failed (no settings or connection error)'));
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
$invoiceNo = $result['InvoiceNo'] ?? '';
if ($rtnCode === '1' && !empty($invoiceNo)) {
$invoice->update([
'status' => Invoice::STATUS_ISSUED,
'invoice_no' => $invoiceNo,
'invoice_date' => $this->safeDate($result['InvoiceDate'] ?? null),
'random_number' => $result['RandomNumber'] ?? $invoice->random_number,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? null,
'last_checked_at' => now(),
]);
return back()->with('success', __('Re-issue success: ') . $invoiceNo);
}
$invoice->update([
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'last_checked_at' => now(),
]);
return back()->with('error', __('Re-issue failed: ') . ($result['RtnMsg'] ?? ''));
}
/** 作廢Invalid用於「已開票未出貨」等情況。 */
public function voidInvoice(Request $request, Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 權限守衛:發票作廢僅限「平台系統管理員」,租戶帳號(含其管理員)一律不得作廢。
// 真正的閘門在後端,前端僅隱藏按鈕無法防止偽造 POST。
if (!Auth::user()->isSystemAdmin()) {
abort(403, __('Only system administrators can void invoices'));
}
// 伺服器端狀態守衛:僅 issued 可作廢(防把已作廢/未開立的發票再作廢)
if ($invoice->status !== Invoice::STATUS_ISSUED) {
return back()->with('error', __('Only issued invoices can be voided'));
}
if (empty($invoice->invoice_no)) {
return back()->with('error', __('No invoice issued, cannot void'));
}
// 送綠界的作廢原因維持固定值(法定欄位、上限 20 字);管理者於視窗輸入的備註僅存內部 remark。
$reason = '未出貨作廢';
$remark = trim((string) $request->input('remark', ''));
$date = $invoice->invoice_date
? Carbon::parse($invoice->invoice_date)->format('Y-m-d')
: '';
$result = $ecpay->invalid($invoice->machine, $invoice->invoice_no, $date, $reason);
if ($result === null) {
return back()->with('error', __('Void failed (settings/connection)'));
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
if ($rtnCode === '1') {
$invoice->update([
'status' => Invoice::STATUS_VOID,
'voided_at' => now(),
'void_reason' => $reason,
'remark' => $remark !== '' ? $remark : $invoice->remark,
]);
return back()->with('success', __('Void success'));
}
return back()->with('error', __('Void failed: ') . ($result['RtnMsg'] ?? ''));
}
/** 更新訂單備註(管理者內部註記)。租戶隔離由 TenantScoped + route-model binding 自動處理。 */
public function updateOrderRemark(Request $request, Order $order)
{
$data = $request->validate([
'remark' => ['nullable', 'string', 'max:1000'],
]);
$order->update(['remark' => $data['remark'] ?? null]);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => __('Remark updated')]);
}
return back()->with('success', __('Remark updated'));
}
/** 更新發票備註(管理者內部註記,與送綠界的 void_reason 無關)。 */
public function updateInvoiceRemark(Request $request, Invoice $invoice)
{
$data = $request->validate([
'remark' => ['nullable', 'string', 'max:1000'],
]);
$invoice->update(['remark' => $data['remark'] ?? null]);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => __('Remark updated')]);
}
return back()->with('success', __('Remark updated'));
}
private function safeDate($raw): ?string
{
if (empty($raw)) {
return null;
}
try {
return Carbon::parse($raw)->toDateString();
} catch (\Throwable $e) {
return null;
}
}
}