first commit

This commit is contained in:
2025-12-30 15:03:19 +08:00
commit c735c36009
902 changed files with 83591 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:categories,name',
]);
Category::create([
'name' => $validated['name'],
'is_active' => true,
]);
return redirect()->back()->with('success', '分類已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Category $category)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:categories,name,' . $category->id,
]);
$category->update($validated);
return redirect()->back()->with('success', '分類已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Category $category)
{
if ($category->products()->count() > 0) {
return redirect()->back()->with('error', '該分類下尚有商品,無法刪除');
}
$category->delete();
return redirect()->back()->with('success', '分類已刪除');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,325 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class InventoryController extends Controller
{
public function index(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
{
$warehouse->load([
'inventories.product.category',
'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction'
]);
$allProducts = \App\Models\Product::with('category')->get();
// 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id, // Frontend expects string
'name' => $product->name,
'type' => $product->category ? $product->category->name : '其他', // 暫時用 Category Name 當 Type
];
});
// 2. 準備 inventories (模擬批號)
// 2. 準備 inventories
// 資料庫結構為 (warehouse_id, product_id) 唯一,故為扁平列表
$inventories = $warehouse->inventories->map(function ($inv) {
return [
'id' => (string) $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'productCode' => $inv->product->code ?? 'N/A',
'unit' => $inv->product->base_unit ?? '個',
'quantity' => (float) $inv->quantity,
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
'batchNumber' => 'BATCH-' . $inv->id, // DB 無批號,暫時模擬,某些 UI 可能還會用到
'expiryDate' => '2099-12-31', // DB 無效期,暫時模擬
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
];
});
// 3. 準備 safetyStockSettings
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
return !is_null($inv->safety_stock);
})->map(function ($inv) {
return [
'id' => 'ss-' . $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
'safetyStock' => (float) $inv->safety_stock,
'createdAt' => $inv->created_at->toIso8601String(),
'updatedAt' => $inv->updated_at->toIso8601String(),
];
})->values();
return \Inertia\Inertia::render('Warehouse/Inventory', [
'warehouse' => $warehouse,
'inventories' => $inventories,
'safetyStockSettings' => $safetyStockSettings,
'availableProducts' => $availableProducts,
]);
}
public function create(\App\Models\Warehouse $warehouse)
{
// 取得所有商品供前端選單使用
$products = \App\Models\Product::select('id', 'name', 'base_unit')->get()->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'unit' => $product->base_unit,
];
});
return \Inertia\Inertia::render('Warehouse/AddInventory', [
'warehouse' => $warehouse,
'products' => $products,
]);
}
public function store(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
{
$validated = $request->validate([
'inboundDate' => 'required|date',
'reason' => 'required|string',
'notes' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
]);
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) {
// 取得或建立庫存紀錄
$inventory = $warehouse->inventories()->firstOrCreate(
['product_id' => $item['productId']],
['quantity' => 0, 'safety_stock' => null]
);
$currentQty = $inventory->quantity;
$newQty = $currentQty + $item['quantity'];
// 更新庫存
$inventory->update(['quantity' => $newQty]);
// 寫入異動紀錄
$inventory->transactions()->create([
'type' => '手動入庫',
'quantity' => $item['quantity'],
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
'actual_time' => $validated['inboundDate'],
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存記錄已儲存成功');
});
}
public function edit(\App\Models\Warehouse $warehouse, $inventoryId)
{
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
if (str_starts_with($inventoryId, 'mock-inv-')) {
return redirect()->back()->with('error', '無法編輯範例資料');
}
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
// 轉換為前端需要的格式
$inventoryData = [
'id' => (string) $inventory->id,
'warehouseId' => (string) $inventory->warehouse_id,
'productId' => (string) $inventory->product_id,
'productName' => $inventory->product->name,
'quantity' => (float) $inventory->quantity,
'batchNumber' => 'BATCH-' . $inventory->id, // Mock
'expiryDate' => '2099-12-31', // Mock
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
'lastOutboundDate' => null,
];
// 整理異動紀錄
$transactions = $inventory->transactions->map(function ($tx) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
];
});
return \Inertia\Inertia::render('Warehouse/EditInventory', [
'warehouse' => $warehouse,
'inventory' => $inventoryData,
'transactions' => $transactions,
]);
}
public function update(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId)
{
// 若是 product ID (舊邏輯),先轉為 inventory
// 但新路由我們傳的是 inventory ID
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$inventory = \App\Models\Inventory::find($inventoryId);
// 如果找不到 (可能是舊路由傳 product ID)
if (!$inventory) {
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
}
if (!$inventory) {
return redirect()->back()->with('error', '找不到庫存紀錄');
}
$validated = $request->validate([
'quantity' => 'required|numeric|min:0',
// 以下欄位改為 nullable支援新表單
'type' => 'nullable|string',
'operation' => 'nullable|in:add,subtract,set',
'reason' => 'nullable|string',
'notes' => 'nullable|string',
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
'batchNumber' => 'nullable|string',
'expiryDate' => 'nullable|date',
'lastInboundDate' => 'nullable|date',
'lastOutboundDate' => 'nullable|date',
]);
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
$currentQty = $inventory->quantity;
$newQty = $validated['quantity'];
// 判斷操作模式
if (isset($validated['operation'])) {
$changeQty = 0;
switch ($validated['operation']) {
case 'add':
$changeQty = $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'subtract':
$changeQty = -$validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'set':
$changeQty = $newQty - $currentQty;
break;
}
} else {
// 來自編輯頁面,直接 Set
$changeQty = $newQty - $currentQty;
}
// 更新庫存
$inventory->update(['quantity' => $newQty]);
// 異動類型映射
$type = $validated['type'] ?? 'adjustment';
$typeMapping = [
'adjustment' => '盤點調整',
'purchase_in' => '採購進貨',
'sales_out' => '銷售出庫',
'return_in' => '退貨入庫',
'return_out' => '退貨出庫',
'transfer_in' => '撥補入庫',
'transfer_out' => '撥補出庫',
];
$chineseType = $typeMapping[$type] ?? $type;
// 如果是編輯頁面來的,可能沒有 type預設為 "盤點調整" 或 "手動編輯"
if (!isset($validated['type'])) {
$chineseType = '手動編輯';
}
// 寫入異動紀錄
// 如果數量沒變,是否要寫紀錄?通常編輯頁面按儲存可能只改了其他欄位(如果有)
// 但因為我們目前只存 quantity如果 quantity 沒變,可以不寫異動,或者寫一筆 0 的異動代表更新屬性
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
'type' => $chineseType,
'quantity' => $changeQty,
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => ($validated['reason'] ?? '編輯頁面更新') . ($validated['notes'] ?? ''),
'actual_time' => now(), // 手動調整設定為當下
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
->with('success', '庫存資料已更新');
});
}
public function destroy(\App\Models\Warehouse $warehouse, $inventoryId)
{
$inventory = \App\Models\Inventory::findOrFail($inventoryId);
// 歸零異動
if ($inventory->quantity > 0) {
$inventory->transactions()->create([
'type' => '手動編輯',
'quantity' => -$inventory->quantity,
'balance_before' => $inventory->quantity,
'balance_after' => 0,
'reason' => '刪除庫存品項',
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
$inventory->delete();
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存品項已刪除');
}
public function history(\App\Models\Warehouse $warehouse, $inventoryId)
{
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
$transactions = $inventory->transactions->map(function ($tx) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
];
});
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => (string) $inventory->id,
'productName' => $inventory->product->name,
'productCode' => $inventory->product->code,
'quantity' => (float) $inventory->quantity,
],
'transactions' => $transactions
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request): Response
{
$query = Product::with('category');
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('brand', 'like', "%{$search}%");
});
}
if ($request->filled('category_id') && $request->category_id !== 'all') {
$query->where('category_id', $request->category_id);
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 10;
}
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
// Define allowed sort fields to prevent SQL injection
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit', 'conversion_rate'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
}
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
$sortDirection = 'desc';
}
// Handle relation sorting (category name) separately if needed, or simple join
if ($sortField === 'category_id') {
// Join categories for sorting by name? Or just by ID?
// Simple approach: sort by ID for now, or join if user wants name sort.
// Let's assume standard field sorting first.
$query->orderBy('category_id', $sortDirection);
} else {
$query->orderBy($sortField, $sortDirection);
}
$products = $query->paginate($perPage)->withQueryString();
$categories = \App\Models\Category::where('is_active', true)->get();
return Inertia::render('Product/Index', [
'products' => $products,
'categories' => $categories,
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit' => 'required|string|max:50',
'large_unit' => 'nullable|string|max:50',
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
'purchase_unit' => 'nullable|string|max:50',
]);
// Auto-generate code
$prefix = 'P';
$lastProduct = Product::withTrashed()->latest('id')->first();
$nextId = $lastProduct ? $lastProduct->id + 1 : 1;
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
$validated['code'] = $code;
$product = Product::create($validated);
return redirect()->back()->with('success', '商品已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit' => 'required|string|max:50',
'large_unit' => 'nullable|string|max:50',
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
'purchase_unit' => 'nullable|string|max:50',
]);
$product->update($validated);
return redirect()->back()->with('success', '商品已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Product $product)
{
$product->delete();
return redirect()->back()->with('success', '商品已刪除');
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace App\Http\Controllers;
use App\Models\PurchaseOrder;
use App\Models\Vendor;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class PurchaseOrderController extends Controller
{
public function index(Request $request)
{
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
// Search
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhereHas('vendor', function($vq) use ($request) {
$vq->where('name', 'like', "%{$request->search}%");
});
});
}
// Filters
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
$query->where('warehouse_id', $request->warehouse_id);
}
// Sorting
$sortField = $request->sort_field ?? 'id';
$sortDirection = $request->sort_direction ?? 'desc';
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
if (in_array($sortField, $allowedSortFields)) {
$query->orderBy($sortField, $sortDirection);
}
$orders = $query->paginate(15)->withQueryString();
return Inertia::render('PurchaseOrder/Index', [
'orders' => $orders,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'sort_field', 'sort_direction']),
'warehouses' => Warehouse::all(['id', 'name']),
]);
}
public function create()
{
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'unit' => $product->base_unit,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
return Inertia::render('PurchaseOrder/Create', [
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unitPrice' => 'required|numeric|min:0',
]);
try {
DB::beginTransaction();
// 生成單號YYYYMMDD001
$today = now()->format('Ymd');
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 3 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -3));
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
} else {
$sequence = '001';
}
$code = $today . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['quantity'] * $item['unitPrice'];
}
// Simple tax calculation (e.g., 5%)
$taxAmount = round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => auth()->id() ?? 1, // Fallback for dev if not using auth
'status' => 'draft',
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
]);
foreach ($validated['items'] as $item) {
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_price' => $item['unitPrice'],
'subtotal' => $item['quantity'] * $item['unitPrice'],
]);
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
}
}
public function show($id)
{
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product'])->findOrFail($id);
return Inertia::render('PurchaseOrder/Show', [
'order' => $order
]);
}
public function edit($id)
{
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'unit' => $product->base_unit,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
return Inertia::render('PurchaseOrder/Create', [
'order' => $order,
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function update(Request $request, $id)
{
$order = PurchaseOrder::findOrFail($id);
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unitPrice' => 'required|numeric|min:0',
]);
try {
DB::beginTransaction();
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['quantity'] * $item['unitPrice'];
}
// Simple tax calculation (e.g., 5%)
$taxAmount = round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
$order->update([
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'status' => $validated['status'],
]);
// Sync items
$order->items()->delete();
foreach ($validated['items'] as $item) {
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_price' => $item['unitPrice'],
'subtotal' => $item['quantity'] * $item['unitPrice'],
]);
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers;
use App\Models\Warehouse;
use App\Models\Inventory;
use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class SafetyStockController extends Controller
{
/**
* 顯示安全庫存設定頁面
*/
public function index(Warehouse $warehouse)
{
$warehouse->load(['inventories.product.category']);
$allProducts = Product::with('category')->get();
// 準備可選商品列表
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'type' => $product->category ? $product->category->name : '其他',
'unit' => $product->base_unit,
];
});
// 準備現有庫存列表 (用於狀態計算)
$inventories = $warehouse->inventories->map(function ($inv) {
return [
'id' => (string) $inv->id,
'productId' => (string) $inv->product_id,
'quantity' => (float) $inv->quantity,
'safetyStock' => (float) $inv->safety_stock,
];
});
// 準備安全庫存設定列表
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
return !is_null($inv->safety_stock);
})->map(function ($inv) {
return [
'id' => (string) $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
'safetyStock' => (float) $inv->safety_stock,
'unit' => $inv->product->base_unit,
'updatedAt' => $inv->updated_at->toIso8601String(),
];
})->values();
return Inertia::render('Warehouse/SafetyStockSettings', [
'warehouse' => $warehouse,
'safetyStockSettings' => $safetyStockSettings,
'inventories' => $inventories,
'availableProducts' => $availableProducts,
]);
}
/**
* 批量儲存安全庫存設定
*/
public function store(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'settings' => 'required|array|min:1',
'settings.*.productId' => 'required|exists:products,id',
'settings.*.quantity' => 'required|numeric|min:0',
]);
DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['settings'] as $item) {
Inventory::updateOrCreate(
[
'warehouse_id' => $warehouse->id,
'product_id' => $item['productId'],
],
[
'safety_stock' => $item['quantity'],
]
);
}
});
return redirect()->back()->with('success', '安全庫存設定已更新');
}
/**
* 更新單筆安全庫存設定
*/
public function update(Request $request, Warehouse $warehouse, Inventory $inventory)
{
$validated = $request->validate([
'safetyStock' => 'required|numeric|min:0',
]);
$inventory->update([
'safety_stock' => $validated['safetyStock'],
]);
return redirect()->back()->with('success', '安全庫存已更新');
}
/**
* 刪除 (歸零) 安全庫存設定
*/
public function destroy(Warehouse $warehouse, Inventory $inventory)
{
$inventory->update([
'safety_stock' => null,
]);
return redirect()->back()->with('success', '安全庫存設定已移除');
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers;
use App\Models\Inventory;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TransferOrderController extends Controller
{
/**
* 儲存撥補單(建立調撥單並執行庫存轉移)
*/
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 可能沒存
]);
return DB::transaction(function () use ($validated) {
// 1. 檢查來源倉庫庫存
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
->where('product_id', $validated['productId'])
->first();
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
throw ValidationException::withMessages([
'quantity' => ['來源倉庫庫存不足'],
]);
}
// 2. 獲取或建立目標倉庫庫存
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $validated['targetWarehouseId'],
'product_id' => $validated['productId'],
],
[
'quantity' => 0,
'safety_stock' => null, // 預設為 null (未設定),而非 0
]
);
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
// 3. 執行庫存轉移 (扣除來源)
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $validated['quantity'];
$sourceInventory->update(['quantity' => $newSourceQty]);
// 記錄來源異動
$sourceInventory->transactions()->create([
'type' => '撥補出庫',
'quantity' => -$validated['quantity'],
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
// 4. 執行庫存轉移 (增加目標)
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $validated['quantity'];
$targetInventory->update(['quantity' => $newTargetQty]);
// 記錄目標異動
$targetInventory->transactions()->create([
'type' => '撥補入庫',
'quantity' => $validated['quantity'],
'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', '撥補單已建立且庫存已轉移');
});
}
/**
* 獲取特定倉庫的庫存列表 (API)
*/
public function getWarehouseInventories(Warehouse $warehouse)
{
$inventories = $warehouse->inventories()
->with(['product:id,name,base_unit,category_id', 'product.category'])
->where('quantity', '>', 0) // 只回傳有庫存的
->get()
->map(function ($inv) {
return [
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
'availableQty' => (float) $inv->quantity,
'unit' => $inv->product->base_unit,
];
});
return response()->json($inventories);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers;
use App\Models\Vendor;
use Illuminate\Http\Request;
class VendorController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(\Illuminate\Http\Request $request): \Inertia\Response
{
$query = Vendor::query();
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('tax_id', 'like', "%{$search}%")
->orWhere('owner', 'like', "%{$search}%")
->orWhere('contact_name', 'like', "%{$search}%");
});
}
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
$allowedSorts = ['id', 'code', 'name', 'owner', 'contact_name', 'phone'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
}
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
$sortDirection = 'desc';
}
$vendors = $query->orderBy($sortField, $sortDirection)
->paginate(10)
->withQueryString();
return \Inertia\Inertia::render('Vendor/Index', [
'vendors' => $vendors,
'filters' => $request->only(['search', 'sort_field', 'sort_direction']),
]);
}
/**
* Display the specified resource.
*/
public function show(Vendor $vendor): \Inertia\Response
{
$vendor->load('products');
return \Inertia\Inertia::render('Vendor/Show', [
'vendor' => $vendor,
'products' => \App\Models\Product::all(),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(\Illuminate\Http\Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'short_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:8',
'owner' => 'nullable|string|max:255',
'contact_name' => 'nullable|string|max:255',
'tel' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'address' => 'nullable|string',
'remark' => 'nullable|string',
]);
// Auto-generate code
$prefix = 'V';
$lastVendor = Vendor::latest('id')->first();
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
$validated['code'] = $code;
Vendor::create($validated);
return redirect()->back()->with('success', '廠商已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(\Illuminate\Http\Request $request, Vendor $vendor)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'short_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:8',
'owner' => 'nullable|string|max:255',
'contact_name' => 'nullable|string|max:255',
'tel' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'address' => 'nullable|string',
'remark' => 'nullable|string',
]);
$vendor->update($validated);
return redirect()->back()->with('success', '廠商資料已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Vendor $vendor)
{
$vendor->delete();
return redirect()->back()->with('success', '廠商已刪除');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Vendor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class VendorProductController extends Controller
{
/**
* 新增供貨商品 (Attach)
*/
public function store(Request $request, Vendor $vendor)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'last_price' => 'nullable|numeric|min:0',
]);
// 檢查是否已存在
if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) {
return redirect()->back()->with('error', '該商品已在供貨清單中');
}
$vendor->products()->attach($validated['product_id'], [
'last_price' => $validated['last_price'] ?? null
]);
return redirect()->back()->with('success', '供貨商品已新增');
}
/**
* 更新供貨商品資訊 (Update Pivot)
*/
public function update(Request $request, Vendor $vendor, $productId)
{
$validated = $request->validate([
'last_price' => 'nullable|numeric|min:0',
]);
$vendor->products()->updateExistingPivot($productId, [
'last_price' => $validated['last_price'] ?? null
]);
return redirect()->back()->with('success', '供貨資訊已更新');
}
/**
* 移除供貨商品 (Detach)
*/
public function destroy(Vendor $vendor, $productId)
{
$vendor->products()->detach($productId);
return redirect()->back()->with('success', '供貨商品已移除');
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Warehouse;
use Inertia\Inertia;
class WarehouseController extends Controller
{
public function index(Request $request)
{
$query = Warehouse::query();
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
}
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
->withCount(['inventories as low_stock_count' => function ($query) {
$query->whereColumn('quantity', '<', 'safety_stock');
}])
->orderBy('created_at', 'desc')
->paginate(10)
->withQueryString();
return Inertia::render('Warehouse/Index', [
'warehouses' => $warehouses,
'filters' => $request->only(['search']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
// Auto-generate code
$prefix = 'WH';
$lastWarehouse = Warehouse::latest('id')->first();
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
$validated['code'] = $code;
Warehouse::create($validated);
return redirect()->back()->with('success', '倉庫已建立');
}
public function update(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$warehouse->update($validated);
return redirect()->back()->with('success', '倉庫資訊已更新');
}
public function destroy(Warehouse $warehouse)
{
// 真實刪除
$warehouse->delete();
return redirect()->back()->with('success', '倉庫已刪除');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
//
];
}
}