feat(倉庫/銷售): 機台貨道明細匯出 + 出貨失敗補償取貨碼

1. 倉庫→機台庫存概覽:點進機台後,矩陣/表格視圖旁加「匯出」下拉(CSV/Excel),
   內容為該機台每個貨道明細(貨道號/商品/庫存/容量/庫存率/效期/批號等)。
2. 銷售紀錄新增「取貨碼」欄位:對「付款成功但出貨失敗/部分」的單顯示「+」,
   點擊確認「是否產生取貨碼(七天內有效)」後,依缺量產生取貨碼(綁機台+商品、
   usage_limit=缺量、冪等 batch_no=COMP{id}、不設 order_id),顯示碼+「待使用」。
   消費者於機台輸入碼走現成 B660 核銷出貨,app 端不需改。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
twsystem1004 2026-07-13 08:00:50 +00:00
parent 1d745c5722
commit 83e1beb7e2
8 changed files with 299 additions and 2 deletions

View File

@ -164,6 +164,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,
@ -175,6 +180,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)
*/

View File

@ -726,6 +726,81 @@ class WarehouseController extends Controller
}
/**
* 匯出單一機台的「每個貨道」明細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 = ['貨道號', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
$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->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]);
}
/**
* AJAX取得單台機台貨道詳情
*/

View File

@ -1620,6 +1620,13 @@
"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",

View File

@ -1621,6 +1621,13 @@
"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": "取貨模組",

View File

@ -233,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>`;

View File

@ -212,6 +212,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') }}
@ -346,6 +350,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;
@ -390,8 +424,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
@ -533,6 +567,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 --}}

View File

@ -260,6 +260,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">

View File

@ -124,6 +124,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 模組 4機台庫存總覽 (Force Route Refresh)
Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory');
Route::get('/machine-inventory/{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機台補貨
@ -158,6 +159,7 @@ 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');
// 取貨碼