style: 修正盤點與盤調畫面 Table Padding 並統一 UI 規範
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m4s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-01-28 18:04:45 +08:00
parent 852370cfe0
commit e5edad4fd0
24 changed files with 3648 additions and 102 deletions

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\InventoryAdjustDoc;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\AdjustService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class AdjustDocController extends Controller
{
protected $adjustService;
public function __construct(AdjustService $adjustService)
{
$this->adjustService = $adjustService;
}
public function index(Request $request)
{
$query = InventoryAdjustDoc::query()
->with(['createdBy', 'postedBy', 'warehouse']);
// 搜尋
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('doc_no', 'like', "%{$search}%")
->orWhere('reason', 'like', "%{$search}%")
->orWhere('remarks', 'like', "%{$search}%");
});
}
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
}
$perPage = $request->input('per_page', 15);
$docs = $query->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()
->through(function ($doc) {
return [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'status' => $doc->status,
'warehouse_name' => $doc->warehouse->name,
'reason' => $doc->reason,
'created_at' => $doc->created_at->format('Y-m-d H:i'),
'posted_at' => $doc->posted_at ? $doc->posted_at->format('Y-m-d H:i') : '-',
'created_by' => $doc->createdBy?->name,
'remarks' => $doc->remarks,
];
});
return Inertia::render('Inventory/Adjust/Index', [
'docs' => $docs,
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]);
}
public function store(Request $request)
{
// 模式 1: 從盤點單建立
if ($request->filled('count_doc_id')) {
$countDoc = InventoryCountDoc::findOrFail($request->count_doc_id);
// 檢查是否已存在對應的盤調單 (避免重複建立)
if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) {
return redirect()->back()->with('error', '此盤點單已建立過盤調單');
}
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
return redirect()->route('inventory.adjust.show', [$doc->id])
->with('success', '已從盤點單生成盤調單');
}
// 模式 2: 一般手動調整 (保留原始邏輯但更新訊息)
$validated = $request->validate([
'warehouse_id' => 'required',
'reason' => 'required|string',
'remarks' => 'nullable|string',
]);
$doc = $this->adjustService->createDoc(
$validated['warehouse_id'],
$validated['reason'],
$validated['remarks'],
auth()->id()
);
return redirect()->route('inventory.adjust.show', [$doc->id])
->with('success', '已建立盤調單');
}
/**
* API: 獲取可盤調的已完成盤點單 (支援掃描單號)
*/
public function getPendingCounts(Request $request)
{
$query = InventoryCountDoc::where('status', 'completed')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('inventory_adjust_docs')
->whereColumn('inventory_adjust_docs.count_doc_id', 'inventory_count_docs.id');
});
if ($request->filled('search')) {
$search = $request->search;
$query->where('doc_no', 'like', "%{$search}%");
}
$counts = $query->limit(10)->get()->map(function($c) {
return [
'id' => (string)$c->id,
'doc_no' => $c->doc_no,
'warehouse_name' => $c->warehouse->name,
'completed_at' => $c->completed_at->format('Y-m-d H:i'),
];
});
return response()->json($counts);
}
public function show(InventoryAdjustDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse']);
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_id' => (string) $doc->warehouse_id,
'warehouse_name' => $doc->warehouse->name,
'status' => $doc->status,
'reason' => $doc->reason,
'remarks' => $doc->remarks,
'created_at' => $doc->created_at->format('Y-m-d H:i'),
'created_by' => $doc->createdBy?->name,
'items' => $doc->items->map(function ($item) {
return [
'id' => (string) $item->id,
'product_id' => (string) $item->product_id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'batch_number' => $item->batch_number,
'unit' => $item->product->baseUnit?->name,
'qty_before' => (float) $item->qty_before,
'adjust_qty' => (float) $item->adjust_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Adjust/Show', [
'doc' => $docData,
]);
}
public function update(Request $request, InventoryAdjustDoc $doc)
{
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
}
// 提交 (items 更新 或 過帳)
if ($request->input('action') === 'post') {
$this->adjustService->post($doc, auth()->id());
return redirect()->route('inventory.adjust.index')
->with('success', '調整單已過帳生效');
}
// 僅儲存資料
$validated = $request->validate([
'items' => 'array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.adjust_qty' => 'required|numeric', // 可以是負數
'items.*.batch_number' => 'nullable|string',
'items.*.notes' => 'nullable|string',
]);
if ($request->has('items')) {
$this->adjustService->updateItems($doc, $validated['items']);
}
// 更新表頭
$doc->update($request->only(['reason', 'remarks']));
return redirect()->back()->with('success', '儲存成功');
}
public function destroy(InventoryAdjustDoc $doc)
{
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
}
$doc->items()->delete();
$doc->delete();
return redirect()->route('inventory.adjust.index')
->with('success', '調整單已刪除');
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\CountService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CountDocController extends Controller
{
protected $countService;
public function __construct(CountService $countService)
{
$this->countService = $countService;
}
public function index(Request $request)
{
$query = InventoryCountDoc::query()
->with(['createdBy', 'completedBy', 'warehouse']);
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
}
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('doc_no', 'like', "%{$search}%")
->orWhere('remarks', 'like', "%{$search}%");
});
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 15;
}
$docs = $query->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()
->through(function ($doc) {
return [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'status' => $doc->status,
'warehouse_name' => $doc->warehouse->name,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-',
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-',
'created_by' => $doc->createdBy?->name,
'remarks' => $doc->remarks,
];
});
return Inertia::render('Inventory/Count/Index', [
'docs' => $docs,
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'warehouse_id' => 'required|exists:warehouses,id',
'remarks' => 'nullable|string|max:255',
]);
$doc = $this->countService->createDoc(
$validated['warehouse_id'],
$validated['remarks'] ?? null,
auth()->id()
);
// 自動執行快照
$this->countService->snapshot($doc);
return redirect()->route('inventory.count.show', [$doc->id])
->with('success', '已建立盤點單並完成庫存快照');
}
public function show(InventoryCountDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_id' => (string) $doc->warehouse_id,
'warehouse_name' => $doc->warehouse->name,
'status' => $doc->status,
'remarks' => $doc->remarks,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
'created_by' => $doc->createdBy?->name,
'items' => $doc->items->map(function ($item) {
return [
'id' => (string) $item->id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'batch_number' => $item->batch_number,
'unit' => $item->product->baseUnit?->name,
'system_qty' => (float) $item->system_qty,
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
'diff_qty' => (float) $item->diff_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Count/Show', [
'doc' => $docData,
]);
}
public function update(Request $request, InventoryCountDoc $doc)
{
if ($doc->status === 'completed') {
return redirect()->back()->with('error', '此盤點單已完成,無法修改');
}
$validated = $request->validate([
'items' => 'array',
'items.*.id' => 'required|exists:inventory_count_items,id',
'items.*.counted_qty' => 'nullable|numeric|min:0',
'items.*.notes' => 'nullable|string',
]);
if (isset($validated['items'])) {
$this->countService->updateCount($doc, $validated['items']);
}
// 如果是按了 "完成盤點"
if ($request->input('action') === 'complete') {
$this->countService->complete($doc, auth()->id());
return redirect()->route('inventory.count.index')
->with('success', '盤點已完成並過帳');
}
return redirect()->back()->with('success', '暫存成功');
}
public function destroy(InventoryCountDoc $doc)
{
if ($doc->status === 'completed') {
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
}
$doc->items()->delete();
$doc->delete();
return redirect()->route('inventory.count.index')
->with('success', '盤點單已刪除');
}
}

View File

@@ -3,135 +3,171 @@
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\TransferService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class TransferOrderController extends Controller
{
/**
* 儲存撥補單(建立調撥單並執行庫存轉移)
*/
protected $transferService;
public function __construct(TransferService $transferService)
{
$this->transferService = $transferService;
}
public function index(Request $request)
{
$query = InventoryTransferOrder::query()
->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
// 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單
if ($request->filled('warehouse_id')) {
$query->where(function ($q) use ($request) {
$q->where('from_warehouse_id', $request->warehouse_id)
->orWhere('to_warehouse_id', $request->warehouse_id);
});
}
$orders = $query->orderByDesc('created_at')
->paginate(15)
->through(function ($order) {
return [
'id' => (string) $order->id,
'doc_no' => $order->doc_no,
'from_warehouse_name' => $order->fromWarehouse->name,
'to_warehouse_name' => $order->toWarehouse->name,
'status' => $order->status,
'created_at' => $order->created_at->format('Y-m-d H:i'),
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i') : '-',
'created_by' => $order->createdBy?->name,
];
});
return Inertia::render('Inventory/Transfer/Index', [
'orders' => $orders,
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'sourceWarehouseId' => 'required|exists:warehouses,id',
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
'productId' => 'required|exists:products,id',
'quantity' => 'required|numeric|min:0.01',
'transferDate' => 'required|date',
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
'notes' => 'nullable|string',
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
'from_warehouse_id' => 'required|exists:warehouses,id',
'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id',
'remarks' => 'nullable|string',
]);
return DB::transaction(function () use ($validated) {
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
->where('product_id', $validated['productId'])
->where('batch_number', $validated['batchNumber'])
->first();
$order = $this->transferService->createOrder(
$validated['from_warehouse_id'],
$validated['to_warehouse_id'],
$validated['remarks'] ?? null,
auth()->id()
);
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
throw ValidationException::withMessages([
'quantity' => ['來源倉庫指定批號庫存不足'],
]);
return redirect()->route('inventory.transfer.show', [$order->id])
->with('success', '已建立調撥單');
}
public function show(InventoryTransferOrder $order)
{
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
$orderData = [
'id' => (string) $order->id,
'doc_no' => $order->doc_no,
'from_warehouse_id' => (string) $order->from_warehouse_id,
'from_warehouse_name' => $order->fromWarehouse->name,
'to_warehouse_id' => (string) $order->to_warehouse_id,
'to_warehouse_name' => $order->toWarehouse->name,
'status' => $order->status,
'remarks' => $order->remarks,
'created_at' => $order->created_at->format('Y-m-d H:i'),
'created_by' => $order->createdBy?->name,
'items' => $order->items->map(function ($item) {
return [
'id' => (string) $item->id,
'product_id' => (string) $item->product_id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'batch_number' => $item->batch_number,
'unit' => $item->product->baseUnit?->name,
'quantity' => (float) $item->quantity,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Transfer/Show', [
'order' => $orderData,
]);
}
public function update(Request $request, InventoryTransferOrder $order)
{
if ($order->status !== 'draft') {
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
}
if ($request->input('action') === 'post') {
try {
$this->transferService->post($order, auth()->id());
return redirect()->route('inventory.transfer.index')
->with('success', '調撥單已過帳完成');
} catch (\Exception $e) {
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
}
}
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $validated['targetWarehouseId'],
'product_id' => $validated['productId'],
'batch_number' => $validated['batchNumber'],
],
[
'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
'total_value' => 0,
'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country,
]
);
$validated = $request->validate([
'items' => 'array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.batch_number' => 'nullable|string',
'items.*.notes' => 'nullable|string',
]);
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
if ($request->has('items')) {
$this->transferService->updateItems($order, $validated['items']);
}
// 3. 執行庫存轉移 (扣除來源)
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $validated['quantity'];
// 設定活動紀錄原因
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
$sourceInventory->quantity = $newSourceQty;
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
$sourceInventory->save();
$order->update($request->only(['remarks']));
// 記錄來源異動
$sourceInventory->transactions()->create([
'type' => '撥補出庫',
'quantity' => -$validated['quantity'],
'unit_cost' => $sourceInventory->unit_cost, // 記錄
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
return redirect()->back()->with('success', '儲存成功');
}
// 4. 執行庫存轉移 (增加目標)
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $validated['quantity'];
public function destroy(InventoryTransferOrder $order)
{
if ($order->status !== 'draft') {
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
}
$order->items()->delete();
$order->delete();
// 設定活動紀錄原因
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
// 確保目標庫存也有成本 (如果是繼承來的)
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
$targetInventory->save();
// 記錄目標異動
$targetInventory->transactions()->create([
'type' => '撥補入庫',
'quantity' => $validated['quantity'],
'unit_cost' => $targetInventory->unit_cost, // 記錄
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
});
return redirect()->route('inventory.transfer.index')
->with('success', '調撥單已刪除');
}
/**
* 獲取特定倉庫的庫存列表 (API)
* 獲取特定倉庫的庫存列表 (API) - 保留給前端選擇商品用
*/
public function getWarehouseInventories(Warehouse $warehouse)
{
$inventories = $warehouse->inventories()
->with(['product.baseUnit', 'product.category'])
->where('quantity', '>', 0) // 只回傳有庫存的
->where('quantity', '>', 0)
->get()
->map(function ($inv) {
return [
'product_id' => (string) $inv->product_id,
'product_name' => $inv->product->name,
'product_code' => $inv->product->code, // Added code
'batch_number' => $inv->batch_number,
'quantity' => (float) $inv->quantity,
'unit_cost' => (float) $inv->unit_cost, // 新增
'total_value' => (float) $inv->total_value, // 新增
'unit_cost' => (float) $inv->unit_cost,
'unit_name' => $inv->product->baseUnit?->name ?? '個',
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
];