style: 修正盤點與盤調畫面 Table Padding 並統一 UI 規範
This commit is contained in:
208
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
208
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal 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', '調整單已刪除');
|
||||
}
|
||||
}
|
||||
158
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
158
app/Modules/Inventory/Controllers/CountDocController.php
Normal 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', '盤點單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
81
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
81
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryAdjustDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'count_doc_id',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'reason',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'ADJ' . $today;
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function countDoc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryAdjustItem::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
}
|
||||
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryAdjustItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'adjust_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'qty_before',
|
||||
'adjust_qty', // 增減數量
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty_before' => 'decimal:2',
|
||||
'adjust_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryAdjustDoc::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
78
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
78
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryCountDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'snapshot_date',
|
||||
'completed_at',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'completed_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_date' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'CNT' . $today;
|
||||
|
||||
// 查詢當天編號最大的單據
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
// 取得最後兩位序號並遞增
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryCountItem::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function completedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'completed_by');
|
||||
}
|
||||
}
|
||||
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryCountItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'count_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'system_qty',
|
||||
'counted_qty',
|
||||
'diff_qty',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'system_qty' => 'decimal:2',
|
||||
'counted_qty' => 'decimal:2',
|
||||
'diff_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
34
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
34
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryTransferItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'transfer_order_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
66
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
66
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryTransferOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'from_warehouse_id',
|
||||
'to_warehouse_id',
|
||||
'status',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$model->doc_no = 'TRF-' . date('YmdHis') . '-' . rand(100, 999);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function fromWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'from_warehouse_id');
|
||||
}
|
||||
|
||||
public function toWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'to_warehouse_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryTransferItem::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use App\Modules\Inventory\Controllers\WarehouseController;
|
||||
use App\Modules\Inventory\Controllers\InventoryController;
|
||||
use App\Modules\Inventory\Controllers\SafetyStockController;
|
||||
use App\Modules\Inventory\Controllers\TransferOrderController;
|
||||
use App\Modules\Inventory\Controllers\CountDocController;
|
||||
use App\Modules\Inventory\Controllers\AdjustDocController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
@@ -54,7 +56,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy');
|
||||
});
|
||||
|
||||
// API: 取得商品在特定倉庫的所有批號
|
||||
// API: 取得商品在特定倉庫的所有批號
|
||||
Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches'])
|
||||
->name('api.warehouses.inventory.batches');
|
||||
});
|
||||
@@ -70,9 +72,35 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 撥補單 (在庫存調撥時使用)
|
||||
// 庫存盤點 (Stock Counting) - Global
|
||||
Route::middleware('permission:inventory.view')->group(function () {
|
||||
Route::get('/inventory/count-docs', [CountDocController::class, 'index'])->name('inventory.count.index');
|
||||
Route::get('/inventory/count-docs/{doc}', [CountDocController::class, 'show'])->name('inventory.count.show');
|
||||
|
||||
Route::middleware('permission:inventory.adjust')->group(function () {
|
||||
Route::post('/inventory/count-docs', [CountDocController::class, 'store'])->name('inventory.count.store');
|
||||
Route::put('/inventory/count-docs/{doc}', [CountDocController::class, 'update'])->name('inventory.count.update');
|
||||
Route::delete('/inventory/count-docs/{doc}', [CountDocController::class, 'destroy'])->name('inventory.count.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
// 庫存盤調 (Stock Adjustment) - Global
|
||||
Route::middleware('permission:inventory.adjust')->group(function () {
|
||||
Route::get('/inventory/adjust-docs', [AdjustDocController::class, 'index'])->name('inventory.adjust.index');
|
||||
Route::get('/inventory/adjust-docs/get-pending-counts', [AdjustDocController::class, 'getPendingCounts'])->name('inventory.adjust.pending-counts');
|
||||
Route::get('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'show'])->name('inventory.adjust.show');
|
||||
Route::post('/inventory/adjust-docs', [AdjustDocController::class, 'store'])->name('inventory.adjust.store');
|
||||
Route::put('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'update'])->name('inventory.adjust.update');
|
||||
Route::delete('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'destroy'])->name('inventory.adjust.destroy');
|
||||
});
|
||||
|
||||
// 撥補單/調撥單 (Transfer Order) - Global
|
||||
Route::middleware('permission:inventory.transfer')->group(function () {
|
||||
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
||||
Route::get('/inventory/transfer-orders', [TransferOrderController::class, 'index'])->name('inventory.transfer.index');
|
||||
Route::get('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'show'])->name('inventory.transfer.show');
|
||||
Route::post('/inventory/transfer-orders', [TransferOrderController::class, 'store'])->name('inventory.transfer.store');
|
||||
Route::put('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'update'])->name('inventory.transfer.update');
|
||||
Route::delete('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'destroy'])->name('inventory.transfer.destroy');
|
||||
});
|
||||
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
|
||||
->middleware('permission:inventory.view')
|
||||
|
||||
153
app/Modules/Inventory/Services/AdjustService.php
Normal file
153
app/Modules/Inventory/Services/AdjustService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdjustService
|
||||
{
|
||||
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
|
||||
{
|
||||
return InventoryAdjustDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'count_doc_id' => $countDocId,
|
||||
'status' => 'draft',
|
||||
'reason' => $reason,
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從盤點單建立盤調單
|
||||
*/
|
||||
public function createFromCountDoc(InventoryCountDoc $countDoc, int $userId): InventoryAdjustDoc
|
||||
{
|
||||
return DB::transaction(function () use ($countDoc, $userId) {
|
||||
// 1. 建立盤調單頭
|
||||
$adjDoc = $this->createDoc(
|
||||
$countDoc->warehouse_id,
|
||||
"盤點調整: " . $countDoc->doc_no,
|
||||
"由盤點單 {$countDoc->doc_no} 自動生成",
|
||||
$userId,
|
||||
$countDoc->id
|
||||
);
|
||||
|
||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||
foreach ($countDoc->items as $item) {
|
||||
if (abs($item->diff_qty) < 0.0001) continue;
|
||||
|
||||
$adjDoc->items()->create([
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'qty_before' => $item->system_qty,
|
||||
'adjust_qty' => $item->diff_qty,
|
||||
'notes' => "盤點差異: " . $item->diff_qty,
|
||||
]);
|
||||
}
|
||||
|
||||
return $adjDoc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調整單內容 (Items)
|
||||
* 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠
|
||||
*/
|
||||
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
$doc->items()->delete();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->where('product_id', $data['product_id'])
|
||||
->where('batch_number', $data['batch_number'] ?? null)
|
||||
->first();
|
||||
|
||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||
|
||||
$doc->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'qty_before' => $qtyBefore,
|
||||
'adjust_qty' => $data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 生效庫存異動
|
||||
*/
|
||||
public function post(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
foreach ($doc->items as $item) {
|
||||
if ($item->adjust_qty == 0) continue;
|
||||
|
||||
// 找尋或建立 Inventory
|
||||
// 若是減少庫存,必須確保 Inventory 存在 (且理論上不能變負? 視策略而定,這裡假設允許變負或由 InventoryService 控管)
|
||||
// 若是增加庫存,若不存在需建立
|
||||
|
||||
$inventory = Inventory::firstOrNew([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
]);
|
||||
|
||||
// 如果是新建立的 object (id 為空),需要初始化 default
|
||||
if (!$inventory->exists) {
|
||||
// 繼承 Product 成本或預設 0 (簡化處理)
|
||||
$inventory->unit_cost = $item->product->cost ?? 0;
|
||||
$inventory->quantity = 0;
|
||||
}
|
||||
|
||||
$oldQty = $inventory->quantity;
|
||||
$newQty = $oldQty + $item->adjust_qty;
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
// 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權)
|
||||
$inventory->total_value = $newQty * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 建立 Transaction
|
||||
$inventory->transactions()->create([
|
||||
'type' => '庫存調整',
|
||||
'quantity' => $item->adjust_qty,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $oldQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => "調整單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$doc->update([
|
||||
'status' => 'posted',
|
||||
'posted_at' => now(),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作廢 (Void)
|
||||
*/
|
||||
public function void(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$doc->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
156
app/Modules/Inventory/Services/CountService.php
Normal file
156
app/Modules/Inventory/Services/CountService.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryCountItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CountService
|
||||
{
|
||||
/**
|
||||
* 建立新的盤點單並執行快照
|
||||
*/
|
||||
public function createDoc(string $warehouseId, string $remarks = null, int $userId): InventoryCountDoc
|
||||
{
|
||||
return DB::transaction(function () use ($warehouseId, $remarks, $userId) {
|
||||
$doc = InventoryCountDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return $doc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行快照:鎖定當前庫存量
|
||||
*/
|
||||
public function snapshot(InventoryCountDoc $doc): void
|
||||
{
|
||||
DB::transaction(function () use ($doc) {
|
||||
// 清除舊的 items (如果有)
|
||||
$doc->items()->delete();
|
||||
|
||||
// 取得該倉庫所有庫存 (包含 quantity = 0 但未軟刪除的)
|
||||
// 這裡可以根據需求決定是否要過濾掉 0 庫存,通常盤點單會希望能看到所有 "帳上有紀錄" 的東西
|
||||
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
foreach ($inventories as $inv) {
|
||||
$items[] = [
|
||||
'count_doc_id' => $doc->id,
|
||||
'product_id' => $inv->product_id,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'system_qty' => $inv->quantity,
|
||||
'counted_qty' => null, // 預設未盤點
|
||||
'diff_qty' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($items)) {
|
||||
InventoryCountItem::insert($items);
|
||||
}
|
||||
|
||||
$doc->update([
|
||||
'status' => 'counting',
|
||||
'snapshot_date' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成盤點:過帳差異
|
||||
*/
|
||||
public function complete(InventoryCountDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
foreach ($doc->items as $item) {
|
||||
// 如果沒有輸入實盤數量,預設跳過或是視為 0?
|
||||
// 安全起見:如果 counted_qty 是 null,表示沒盤到,跳過不處理 (或者依業務邏輯視為0)
|
||||
// 這裡假設前端會確保有送出資料,若 null 則不做異動
|
||||
if (is_null($item->counted_qty)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $item->counted_qty - $item->system_qty;
|
||||
|
||||
// 如果無差異,更新 item 狀態即可 (diff_qty 已經是 computed field 或在儲存時計算)
|
||||
// 這裡 update 一下 diff_qty 以防萬一
|
||||
$item->update(['diff_qty' => $diff]);
|
||||
|
||||
if (abs($diff) > 0.0001) {
|
||||
// 找回原本的 Inventory
|
||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
if (!$inventory) {
|
||||
// 如果原本沒庫存紀錄 (例如是新增的盤點項目),需要新建 Inventory
|
||||
// 但目前 snapshot 邏輯只抓現有。若允許 "盤盈" (發現不在帳上的),需要額外邏輯
|
||||
// 暫時略過 "新增 Inventory" 的複雜邏輯,假設只能針對 existing batch 調整
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldQty = $inventory->quantity;
|
||||
$newQty = $oldQty + $diff;
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
$inventory->total_value = $inventory->unit_cost * $newQty;
|
||||
$inventory->save();
|
||||
|
||||
// 寫入 Transaction
|
||||
$inventory->transactions()->create([
|
||||
'type' => '盤點調整',
|
||||
'quantity' => $diff,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $oldQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => "盤點單 {$doc->doc_no} 過帳",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$doc->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'completed_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新盤點數量
|
||||
*/
|
||||
public function updateCount(InventoryCountDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
foreach ($itemsData as $data) {
|
||||
$item = $doc->items()->find($data['id']);
|
||||
if ($item) {
|
||||
$countedQty = $data['counted_qty'];
|
||||
$diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0;
|
||||
|
||||
$item->update([
|
||||
'counted_qty' => $countedQty,
|
||||
'diff_qty' => $diff,
|
||||
'notes' => $data['notes'] ?? $item->notes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
152
app/Modules/Inventory/Services/TransferService.php
Normal file
152
app/Modules/Inventory/Services/TransferService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
/**
|
||||
* 建立調撥單草稿
|
||||
*/
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder
|
||||
{
|
||||
return InventoryTransferOrder::create([
|
||||
'from_warehouse_id' => $fromWarehouseId,
|
||||
'to_warehouse_id' => $toWarehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調撥單明細
|
||||
*/
|
||||
public function updateItems(InventoryTransferOrder $order, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($order, $itemsData) {
|
||||
$order->items()->delete();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
$order->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的)
|
||||
*/
|
||||
public function post(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$fromWarehouse = $order->fromWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 處理來源倉 (扣除)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足"],
|
||||
]);
|
||||
}
|
||||
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
// 更新總值 (假設成本不變)
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源交易
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '調撥出庫',
|
||||
'quantity' => -$item->quantity,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
// 2. 處理目的倉 (增加)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $order->to_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
],
|
||||
[
|
||||
'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,
|
||||
]
|
||||
);
|
||||
|
||||
// 若是新建立的,且成本為0,確保繼承成本
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
||||
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
||||
$targetInventory->save();
|
||||
|
||||
// 記錄目的交易
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '調撥入庫',
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'posted_at' => now(),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function void(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$order->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user