star-cloud/app/Http/Controllers/Admin/SalesController.php
sky121113 b7e8401a99 [FIX] 銷售紀錄未付款單顯示:已棄單改未支付、未出貨顯示 --
1. 付款狀態「已棄單(abandoned)」顯示文字改為「未支付」;新增 Unpaid 語系、移除孤兒 Abandoned key,三語系(zh_TW/en/ja)對齊排序。
2. 出貨狀態:非付款成功(未完成 pending/支付失敗 failed/未支付 abandoned)的單從未出貨,一律顯示「--」,避免 delivery_status 欄位 DB 預設值 1 誤顯示為「出貨成功」。
3. 判斷邏輯收斂至 Order::hasDeliveryOutcome(),桌機表格、手機卡片、明細面板與匯出 delivery_status_label accessor 共用同一基準。
4. 補齊匯出訂單狀態對照表原本缺漏的 failed/abandoned 標籤。

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

1343 lines
54 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');
$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,
'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);
}
// 發票專用過濾器狀態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') {
$query = PickupCode::with(['machine.slots.product', 'creator', 'order'])->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') {
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order']);
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)
{
$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') {
$query = PassCode::with(['machine', 'creator'])->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') {
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode']);
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)
{
$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') {
$query = WelcomeGift::with(['machine', 'creator'])->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') {
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no']);
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)
{
$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->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;
}
}
}