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,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
]);
}
}

View 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,
]);
}
}
});
}
}

View 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
]);
}
}