feat: 整合門市領料日誌、API 文件存取、修改庫存與併發編號問題、供應商商品內聯編輯及日誌 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s

This commit is contained in:
2026-03-02 16:42:12 +08:00
parent 7dac2d1f77
commit 0a955fb993
33 changed files with 1424 additions and 853 deletions

View File

@@ -32,6 +32,7 @@ class ActivityLogController extends Controller
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
];
}

View File

@@ -186,8 +186,14 @@ class InventoryController extends Controller
]);
return DB::transaction(function () use ($validated, $warehouse) {
// 修正時間精度:手動入庫亦補上當下時分秒
$inboundDateTime = $validated['inboundDate'] . ' ' . date('H:i:s');
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
$dt->setTimeFrom(now());
} else {
$dt->setSecond(now()->second);
}
$inboundDateTime = $dt->toDateTimeString();
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
'inboundDate' => $inboundDateTime,

View File

@@ -284,7 +284,7 @@ class ProductController extends Controller
*/
public function template()
{
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
return Excel::download(new ProductTemplateExport, '商品匯入範本.xlsx');
}
/**

View File

@@ -143,15 +143,16 @@ class StoreRequisitionController extends Controller
'items.*.requested_qty.min' => '需求數量必須大於 0',
]);
$submitImmediately = $request->boolean('submit_immediately');
$requisition = $this->service->create(
$request->only(['store_warehouse_id', 'remark']),
$request->items,
auth()->id()
auth()->id(),
$submitImmediately
);
// 如果需要直接提交
if ($request->boolean('submit_immediately')) {
$this->service->submit($requisition, auth()->id());
if ($submitImmediately) {
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已提交審核');
}

View File

@@ -99,6 +99,24 @@ class TransferOrderController extends Controller
auth()->id(),
$transitWarehouseId
);
// 手動發送「已建立」日誌,因為服務層使用了 saveQuietly 抑制自動日誌
activity()
->performedOn($order)
->causedBy(auth()->id())
->event('created')
->withProperties([
'attributes' => [
'doc_no' => $order->doc_no,
'from_warehouse_id' => $order->from_warehouse_id,
'to_warehouse_id' => $order->to_warehouse_id,
'transit_warehouse_id' => $order->transit_warehouse_id,
'remarks' => $order->remarks,
'status' => $order->status,
'created_by' => $order->created_by,
]
])
->log('created');
if ($request->input('instant_post') === true) {
try {

View File

@@ -26,8 +26,7 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
// 修正時間精度:將選定的日期與「現在的時分秒」結合
// 這樣既能保留使用者選的日期,又能提供精確的紀錄時點排順序
$currentTime = date('H:i:s');
$this->inboundDate = $inboundDate . ' ' . $currentTime;
$this->inboundDate = \Illuminate\Support\Carbon::parse($inboundDate)->setTimeFrom(now())->toDateTimeString();
$this->notes = $notes;
}

View File

@@ -9,10 +9,43 @@ use Illuminate\Validation\Rule;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapping
/**
* 商品匯入主類別
*
* 實作 WithMultipleSheets 以限定只讀取第一個工作表(資料頁),
* 跳過第二個工作表(填寫說明頁),避免說明頁的資料被誤匯入並觸發驗證錯誤。
*/
class ProductImport implements WithMultipleSheets
{
public function __construct()
{
// 禁用標題格式化,保留中文標題
HeadingRowFormatter::default('none');
}
/**
* 指定只處理第一個工作表 (index 0)
*/
public function sheets(): array
{
return [
0 => new ProductDataSheetImport(),
];
}
}
/**
* 商品匯入 - 資料工作表處理類別
*
* 負責實際的資料解析、驗證與儲存邏輯。
* 只會被套用到 Excel 的第一個工作表(資料頁)。
*/
class ProductDataSheetImport implements ToModel, WithHeadingRow, WithValidation, WithMapping, SkipsEmptyRows
{
private $categories;
private $units;
@@ -20,9 +53,6 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
public function __construct()
{
// 禁用標題格式化,保留中文標題
HeadingRowFormatter::default('none');
// 快取所有類別與單位,避免 N+1 查詢
$this->categories = Category::pluck('id', 'name');
$this->units = Unit::pluck('id', 'name');
@@ -30,28 +60,37 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
}
/**
* @param mixed $row
*
* @return array
*/
* 資料映射:將 Excel 原始標題(含「(選填)」)對應到乾淨的鍵名
*
* 注意WithValidation 驗證的是 map() 之前的原始資料,
* 因此 rules() 中的鍵名必須匹配 Excel 的原始標題。
* map() 的返回值只影響 model() 接收到的資料。
*/
public function map($row): array
{
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
if (isset($row['商品代號'])) {
$row['商品代號'] = (string) $row['商品代號'];
}
if (isset($row['條碼'])) {
$row['條碼'] = (string) $row['條碼'];
}
return $row;
$code = $row['商品代號(選填)'] ?? $row['商品代號'] ?? null;
$barcode = $row['條碼(選填)'] ?? $row['條碼'] ?? null;
return [
'商品代號' => $code !== null ? (string)$code : null,
'條碼' => $barcode !== null ? (string)$barcode : null,
'商品名稱' => $row['商品名稱'] ?? null,
'類別名稱' => $row['類別名稱'] ?? null,
'品牌' => $row['品牌'] ?? null,
'規格' => $row['規格'] ?? null,
'基本單位' => $row['基本單位'] ?? null,
'大單位' => $row['大單位'] ?? null,
'換算率' => isset($row['換算率']) ? (float)$row['換算率'] : null,
'成本價' => isset($row['成本價']) ? (float)$row['成本價'] : null,
'售價' => isset($row['售價']) ? (float)$row['售價'] : null,
'會員價' => isset($row['會員價']) ? (float)$row['會員價'] : null,
'批發價' => isset($row['批發價']) ? (float)$row['批發價'] : null,
];
}
/**
* @param array $row
*
* @return \Illuminate\Database\Eloquent\Model|null
*/
* @param array $row (map() 回傳的乾淨鍵名陣列)
*/
public function model(array $row)
{
// 查找關聯 ID
@@ -96,13 +135,17 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
return null; // 返回 null因為 Service 已經處理完儲存
}
}
/**
* 驗證規則
*
* 鍵名必須匹配 Excel 原始標題(含「(選填)」後綴),
* 因為 WithValidation 驗證的是 map() 之前的原始資料。
*/
public function rules(): array
{
return [
'商品代號' => ['nullable', 'string', 'min:2', 'max:8'],
'條碼' => ['nullable', 'string'],
'商品代號(選填)' => ['nullable', 'string', 'min:2', 'max:8'],
'條碼(選填)' => ['nullable', 'string'],
'商品名稱' => ['required', 'string'],
'類別名稱' => ['required', function($attribute, $value, $fail) {
if (!isset($this->categories[$value])) {
@@ -127,4 +170,16 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
'批發價' => ['nullable', 'numeric', 'min:0'],
];
}
/**
* 自訂驗證錯誤訊息的欄位名稱
* 把含 "(選填)" 後綴的欄位顯示為友善名稱
*/
public function customValidationAttributes(): array
{
return [
'商品代號(選填)' => '商品代號',
'條碼(選填)' => '條碼',
];
}
}

View File

@@ -29,12 +29,27 @@ class Category extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
}

View File

@@ -40,6 +40,47 @@ class GoodsReceipt extends Model
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['doc_no'] = $this->code;
$snapshot['warehouse_name'] = $this->warehouse?->name;
if (!isset($snapshot['vendor_name']) && $this->vendor_id) {
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
->getVendorsByIds([$this->vendor_id])->first();
$snapshot['vendor_name'] = $vendor?->name;
}
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = app(\App\Modules\Core\Contracts\CoreServiceInterface::class)->getUser($data[$f])?->name;
}
}
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
}
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
->getVendorsByIds([$data['vendor_id']])->first();
$data['vendor_id'] = $vendor?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
public function items()
{
return $this->hasMany(GoodsReceiptItem::class);

View File

@@ -147,7 +147,7 @@ class Inventory extends Model
{
if ($amount <= 0) return;
$this->reserved_quantity += $amount;
$this->save();
$this->saveQuietly();
}
/**
@@ -157,7 +157,7 @@ class Inventory extends Model
{
if ($amount <= 0) return;
$this->reserved_quantity = max(0, $this->reserved_quantity - $amount);
$this->save();
$this->saveQuietly();
}
/**

View File

@@ -36,21 +36,23 @@ class InventoryTransferOrder extends Model
if ($eventName === 'created') {
$activity->description = 'created';
} elseif ($eventName === 'updated') {
// 如果屬性中有 status 且變更為 completed將描述改為 posted
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
$activity->description = 'posted';
$eventName = 'posted'; // 供後續快照邏輯判定
$eventName = 'posted';
} else {
$activity->description = 'updated';
}
}
// 處理倉庫 ID 轉名稱
// 處理 ID 轉名稱 (核心:支援 attributes 與 old 的自動轉換)
$idToNameFields = [
'from_warehouse_id' => 'fromWarehouse',
'to_warehouse_id' => 'toWarehouse',
'transit_warehouse_id' => 'transitWarehouse',
'created_by' => 'createdBy',
'posted_by' => 'postedBy',
'dispatched_by' => 'dispatchedBy',
'received_by' => 'receivedBy',
];
foreach (['attributes', 'old'] as $part) {
@@ -58,14 +60,20 @@ class InventoryTransferOrder extends Model
foreach ($idToNameFields as $idField => $relation) {
if (isset($properties[$part][$idField])) {
$id = $properties[$part][$idField];
$nameField = str_replace('_id', '_name', $idField);
if (!$id) continue;
$nameField = str_replace('_id', '_name', $idField);
$name = null;
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
$name = $this->$relation->name;
} else {
$model = $this->$relation()->getRelated()->find($id);
$name = $model ? $model->name : "ID: $id";
try {
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
$name = $this->$relation->name;
} else {
$relatedModel = $this->$relation()->getRelated();
$model = $relatedModel->find($id);
$name = $model ? ($model->name ?? $model->display_name ?? "ID: $id") : "ID: $id";
}
} catch (\Exception $e) {
$name = "ID: $id";
}
$properties[$part][$nameField] = $name;
}
@@ -73,7 +81,7 @@ class InventoryTransferOrder extends Model
}
}
// 基本單據資訊快照 (包含單號、來源、目的地)
// 基本單據資訊快照
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
@@ -85,8 +93,6 @@ class InventoryTransferOrder extends Model
// 移除輔助欄位與雜訊
if (isset($properties['attributes'])) {
unset($properties['attributes']['from_warehouse_name']);
unset($properties['attributes']['to_warehouse_name']);
unset($properties['attributes']['activityProperties']);
unset($properties['attributes']['updated_at']);
}
@@ -94,7 +100,7 @@ class InventoryTransferOrder extends Model
unset($properties['old']['updated_at']);
}
// 合併暫存屬性 (例如 items_diff)
// 合併暫存屬性 (重要:例如 items_diff)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}

View File

@@ -41,6 +41,11 @@ class StoreRequisition extends Model
->dontSubmitEmptyLogs();
}
/**
* @var array 暫存的活動紀錄屬性 (不會存入資料庫)
*/
public $activityProperties = [];
/**
* 自定義日誌屬性,解析 ID 為名稱
*/
@@ -48,22 +53,90 @@ class StoreRequisition extends Model
{
$properties = $activity->properties->toArray();
// 處置日誌事件與狀態中文化
$statusMap = [
'draft' => '草稿',
'pending' => '待審核',
'approved' => '已核准',
'rejected' => '已駁回',
'completed' => '已完成',
];
// 處理 ID 轉名稱
$idToNameFields = [
'store_warehouse_id' => 'storeWarehouse',
'supply_warehouse_id' => 'supplyWarehouse',
'created_by' => 'createdBy',
'approved_by' => 'approvedBy',
'transfer_order_id' => 'transferOrder',
];
foreach (['attributes', 'old'] as $part) {
if (isset($properties[$part])) {
// 1. 解析狀態中文並替換原始 status 欄位
if (isset($properties[$part]['status'])) {
$statusValue = $properties[$part]['status'];
$properties[$part]['status'] = $statusMap[$statusValue] ?? $statusValue;
}
// 2. 解析關連名稱
foreach ($idToNameFields as $idField => $relation) {
if (isset($properties[$part][$idField])) {
$id = $properties[$part][$idField];
if (!$id) continue;
$nameField = str_replace('_id', '_name', $idField);
if (str_contains($idField, '_by')) {
$nameField = str_replace('_by', '_user_name', $idField);
}
$name = null;
try {
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
// 特別處理調撥單號
$name = ($relation === 'transferOrder') ? $this->$relation->doc_no : $this->$relation->name;
} else {
$relatedModel = $this->$relation()->getRelated();
$model = $relatedModel->find($id);
if ($model) {
$name = ($relation === 'transferOrder') ? ($model->doc_no ?? "ID: $id") : ($model->name ?? "ID: $id");
} else {
$name = "ID: $id";
}
}
} catch (\Exception $e) {
$name = "ID: $id";
}
$properties[$part][$nameField] = $name;
// 移除原生的技術 ID 欄位,讓詳情更乾淨
unset($properties[$part][$idField]);
}
}
}
}
// 基本單據資訊快照
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
'store_warehouse_name' => $this->storeWarehouse?->name,
'supply_warehouse_name' => $this->supplyWarehouse?->name,
'status' => $this->status,
'status' => $statusMap[$this->status] ?? $this->status,
];
// 移除雜訊欄位
// 移除雜訊與重複欄位
if (isset($properties['attributes'])) {
unset($properties['attributes']['updated_at']);
unset($properties['attributes']['activityProperties']);
}
if (isset($properties['old'])) {
unset($properties['old']['updated_at']);
}
// 合併暫存屬性 (例如 items_diff)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}
$activity->properties = collect($properties);
}

View File

@@ -34,12 +34,27 @@ class Unit extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
}

View File

@@ -37,12 +37,31 @@ class Warehouse extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
if (isset($data['default_transit_warehouse_id']) && is_numeric($data['default_transit_warehouse_id'])) {
$data['default_transit_warehouse_id'] = self::find($data['default_transit_warehouse_id'])?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}

View File

@@ -5,10 +5,17 @@ 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 App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Facades\DB;
class AdjustService
{
protected InventoryServiceInterface $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
{
return InventoryAdjustDoc::create([
@@ -161,29 +168,20 @@ class AdjustService
'batch_number' => $item->batch_number,
]);
// 如果是新建立的 object (id 為空),需要初始化 default
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
if (!$inventory->exists) {
$inventory->unit_cost = $item->product->cost ?? 0;
$inventory->quantity = 0;
$inventory->total_value = 0;
$inventory->saveQuietly();
}
$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' => '庫存調整',
$this->inventoryService->adjustInventory($inventory, [
'operation' => 'add',
'quantity' => $item->adjust_qty,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $oldQty,
'balance_after' => $newQty,
'type' => 'adjustment',
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
'actual_time' => now(),
'user_id' => $userId,
'notes' => $item->notes,
]);
}

View File

@@ -60,14 +60,6 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
});
}
/**
* Update an existing Goods Receipt.
*
* @param GoodsReceipt $goodsReceipt
* @param array $data
* @return GoodsReceipt
* @throws \Exception
*/
public function update(GoodsReceipt $goodsReceipt, array $data)
{
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
@@ -75,14 +67,42 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
return DB::transaction(function () use ($goodsReceipt, $data) {
$goodsReceipt->update([
$goodsReceipt->fill([
'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id,
'received_date' => $data['received_date'] ?? $goodsReceipt->received_date,
'remarks' => $data['remarks'] ?? $goodsReceipt->remarks,
]);
$dirty = $goodsReceipt->getDirty();
$oldAttributes = [];
$newAttributes = [];
foreach ($dirty as $key => $value) {
$oldAttributes[$key] = $goodsReceipt->getOriginal($key);
$newAttributes[$key] = $value;
}
// 儲存但不觸發事件,以避免重複記錄
$goodsReceipt->saveQuietly();
// 捕捉包含商品名稱的舊項目以進行比對
$oldItemsCollection = $goodsReceipt->items()->get();
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
$product = $oldProducts->get($item->product_id);
return [
'id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $product?->name ?? 'Unknown',
'quantity_received' => (float) $item->quantity_received,
'unit_price' => (float) $item->unit_price,
'total_amount' => (float) $item->total_amount,
];
})->keyBy('product_id');
if (isset($data['items'])) {
// Simple strategy: delete existing items and recreate
$goodsReceipt->items()->delete();
foreach ($data['items'] as $itemData) {
@@ -99,6 +119,75 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
}
// 計算項目差異
$itemDiffs = [
'added' => [],
'removed' => [],
'updated' => [],
];
$newItemsCollection = $goodsReceipt->items()->get();
$newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray();
$newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id');
$newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) {
$product = $newProducts->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $product?->name ?? 'Unknown',
'quantity_received' => (float) $item->quantity_received,
'unit_price' => (float) $item->unit_price,
'total_amount' => (float) $item->total_amount,
];
})->keyBy('product_id');
foreach ($oldItems as $productId => $oldItem) {
if (!$newItemsFormatted->has($productId)) {
$itemDiffs['removed'][] = $oldItem;
}
}
foreach ($newItemsFormatted as $productId => $newItem) {
if (!$oldItems->has($productId)) {
$itemDiffs['added'][] = $newItem;
} else {
$oldItem = $oldItems[$productId];
if (
$oldItem['quantity_received'] != $newItem['quantity_received'] ||
$oldItem['unit_price'] != $newItem['unit_price'] ||
$oldItem['total_amount'] != $newItem['total_amount']
) {
$itemDiffs['updated'][] = [
'product_name' => $newItem['product_name'],
'old' => [
'quantity_received' => $oldItem['quantity_received'],
'unit_price' => $oldItem['unit_price'],
'total_amount' => $oldItem['total_amount'],
],
'new' => [
'quantity_received' => $newItem['quantity_received'],
'unit_price' => $newItem['unit_price'],
'total_amount' => $newItem['total_amount'],
]
];
}
}
}
// 如果有變更,手動觸發單一合併日誌
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
activity()
->performedOn($goodsReceipt)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => $newAttributes,
'old' => $oldAttributes,
'items_diff' => $itemDiffs,
])
->log('updated');
}
return $goodsReceipt->fresh('items');
});
}
@@ -162,23 +251,35 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
private function generateCode(string $date)
private function generateCode(string $date): string
{
// Format: GR-YYYYMMDD-NN
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc')
->lockForUpdate()
->first();
// 使用 Cache Lock 防止併發時產生重複單號
$lock = \Illuminate\Support\Facades\Cache::lock('gr_code_generation', 10);
if ($last) {
$seq = intval(substr($last->code, -2)) + 1;
} else {
$seq = 1;
if (!$lock->get()) {
throw new \Exception('系統忙碌中,進貨單號生成失敗,請稍後再試');
}
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
try {
// Format: GR-YYYYMMDD-NN
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc')
->first();
if ($last) {
$seq = intval(substr($last->code, -2)) + 1;
} else {
$seq = 1;
}
$code = $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
return $code;
} finally {
$lock->release();
}
}
/**

View File

@@ -181,11 +181,11 @@ class InventoryService implements InventoryServiceInterface
// 更新其他可能變更的欄位 (如最後入庫日)
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
$inventory->save();
$inventory->saveQuietly();
} else {
// 若不存在,則建立新紀錄
$unitCost = $data['unit_cost'] ?? 0;
$inventory = Inventory::create([
$inventory = new Inventory([
'warehouse_id' => $data['warehouse_id'],
'product_id' => $data['product_id'],
'quantity' => $data['quantity'],
@@ -199,9 +199,10 @@ class InventoryService implements InventoryServiceInterface
'quality_status' => $data['quality_status'] ?? 'normal',
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
]);
$inventory->saveQuietly();
}
\App\Modules\Inventory\Models\InventoryTransaction::create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => '入庫',
'quantity' => $data['quantity'],
@@ -214,6 +215,7 @@ class InventoryService implements InventoryServiceInterface
'user_id' => auth()->id(),
'actual_time' => now(),
]);
$transaction->saveQuietly();
return $inventory;
});
@@ -225,13 +227,12 @@ class InventoryService implements InventoryServiceInterface
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
$balanceBefore = $inventory->quantity;
$inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
// 需要手動更新總價值
$inventory->refresh();
// 手動更新以配合 saveQuietly 消除日誌
$inventory->quantity -= $quantity;
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
$inventory->saveQuietly();
\App\Modules\Inventory\Models\InventoryTransaction::create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => '出庫',
'quantity' => -$quantity,
@@ -244,6 +245,7 @@ class InventoryService implements InventoryServiceInterface
'user_id' => auth()->id(),
'actual_time' => now(),
]);
$transaction->saveQuietly();
});
}
@@ -825,7 +827,8 @@ class InventoryService implements InventoryServiceInterface
}
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => $chineseType,
'quantity' => $changeQty,
'unit_cost' => $inventory->unit_cost,
@@ -835,6 +838,7 @@ class InventoryService implements InventoryServiceInterface
'actual_time' => now(),
'user_id' => auth()->id(),
]);
$transaction->saveQuietly();
}
});
}

View File

@@ -23,24 +23,77 @@ class StoreRequisitionService
/**
* 建立叫貨單(含明細)
*/
public function create(array $data, array $items, int $userId): StoreRequisition
public function create(array $data, array $items, int $userId, bool $submitImmediately = false): StoreRequisition
{
return DB::transaction(function () use ($data, $items, $userId) {
$requisition = StoreRequisition::create([
return DB::transaction(function () use ($data, $items, $userId, $submitImmediately) {
$requisition = new StoreRequisition([
'store_warehouse_id' => $data['store_warehouse_id'],
'status' => 'draft',
'status' => $submitImmediately ? 'pending' : 'draft',
'submitted_at' => $submitImmediately ? now() : null,
'remark' => $data['remark'] ?? null,
'created_by' => $userId,
]);
// 手動產生單號,因為 saveQuietly 會繞過模型事件
if (empty($requisition->doc_no)) {
$today = date('Ymd');
$prefix = 'SR-' . $today . '-';
$lastDoc = StoreRequisition::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';
}
$requisition->doc_no = $prefix . $nextNumber;
}
// 靜默建立以抑制自動日誌
$requisition->saveQuietly();
$diff = ['added' => [], 'removed' => [], 'updated' => []];
foreach ($items as $item) {
$requisition->items()->create([
'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null,
]);
$product = \App\Modules\Inventory\Models\Product::find($item['product_id']);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity' => (float)$item['requested_qty'],
'remark' => $item['remark'] ?? null,
]
];
}
// 如果需直接提交,觸發通知
if ($submitImmediately) {
$this->notifyApprovers($requisition, 'submitted', $userId);
}
// 手動發送高品質日誌
activity()
->performedOn($requisition)
->causedBy($userId)
->event('created')
->withProperties([
'items_diff' => $diff,
'attributes' => [
'doc_no' => $requisition->doc_no,
'store_warehouse_id' => $requisition->store_warehouse_id,
'status' => $requisition->status,
'remark' => $requisition->remark,
'created_by' => $requisition->created_by,
'submitted_at' => $requisition->submitted_at,
]
])
->log('created');
return $requisition->load('items');
});
}
@@ -57,13 +110,74 @@ class StoreRequisitionService
}
return DB::transaction(function () use ($requisition, $data, $items) {
$requisition->update([
'store_warehouse_id' => $data['store_warehouse_id'],
'remark' => $data['remark'] ?? null,
'reject_reason' => null, // 清除駁回原因
]);
// 擷取舊狀態供日誌對照
$oldAttributes = [
'store_warehouse_id' => $requisition->store_warehouse_id,
'remark' => $requisition->remark,
];
// 重建明細
// 手動更新屬性
$requisition->store_warehouse_id = $data['store_warehouse_id'];
$requisition->remark = $data['remark'] ?? null;
$requisition->reject_reason = null; // 清除駁回原因
// 品項對比邏輯
$oldItems = $requisition->items()->with('product:id,name')->get();
$oldItemsMap = $oldItems->keyBy('product_id');
$newItemsMap = collect($items)->keyBy('product_id');
$diff = [
'added' => [],
'removed' => [],
'updated' => [],
];
// 1. 處理更新與新增
foreach ($items as $itemData) {
$productId = $itemData['product_id'];
$newQty = (float)$itemData['requested_qty'];
$newRemark = $itemData['remark'] ?? null;
if ($oldItemsMap->has($productId)) {
$oldItem = $oldItemsMap->get($productId);
if ((float)$oldItem->requested_qty !== $newQty || $oldItem->remark !== $newRemark) {
$diff['updated'][] = [
'product_name' => $oldItem->product?->name ?? '未知商品',
'old' => [
'quantity' => (float)$oldItem->requested_qty,
'remark' => $oldItem->remark,
],
'new' => [
'quantity' => $newQty,
'remark' => $newRemark,
]
];
}
$oldItemsMap->forget($productId);
} else {
$product = \App\Modules\Inventory\Models\Product::find($productId);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity' => $newQty,
'remark' => $newRemark,
]
];
}
}
// 2. 處理移除
foreach ($oldItemsMap as $productId => $oldItem) {
$diff['removed'][] = [
'product_name' => $oldItem->product?->name ?? '未知商品',
'old' => [
'quantity' => (float)$oldItem->requested_qty,
'remark' => $oldItem->remark,
]
];
}
// 儲存實際變動
$requisition->items()->delete();
foreach ($items as $item) {
$requisition->items()->create([
@@ -73,6 +187,32 @@ class StoreRequisitionService
]);
}
// 檢查是否有任何變動 (主表或明細)
$isDirty = $requisition->isDirty();
$hasItemsDiff = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
if ($isDirty || $hasItemsDiff) {
// 擷取新狀態
$newAttributes = [
'store_warehouse_id' => $requisition->store_warehouse_id,
'remark' => $requisition->remark,
];
// 靜默更新
$requisition->saveQuietly();
// 手動發送紀錄
activity()
->performedOn($requisition)
->event('updated')
->withProperties([
'items_diff' => $diff,
'attributes' => $newAttributes,
'old' => $oldAttributes
])
->log('updated');
}
return $requisition->load('items');
});
}
@@ -241,6 +381,27 @@ class StoreRequisitionService
if (!empty($transferItems)) {
$this->transferService->updateItems($transferOrder, $transferItems);
// 手動發送調撥單的「已建立」合併日誌,包含初始明細
activity()
->performedOn($transferOrder)
->causedBy($userId)
->event('created')
->withProperties(array_merge(
['items_diff' => $transferOrder->activityProperties['items_diff'] ?? []],
[
'attributes' => [
'doc_no' => $transferOrder->doc_no,
'from_warehouse_id' => $transferOrder->from_warehouse_id,
'to_warehouse_id' => $transferOrder->to_warehouse_id,
'transit_warehouse_id' => $transferOrder->transit_warehouse_id,
'remarks' => $transferOrder->remarks,
'status' => $transferOrder->status,
'created_by' => $transferOrder->created_by,
]
]
))
->log('created');
}
// 更新叫貨單狀態

View File

@@ -9,8 +9,17 @@ use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
class TransferService
{
protected InventoryServiceInterface $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 建立調撥單草稿
*/
@@ -24,7 +33,7 @@ class TransferService
}
}
return InventoryTransferOrder::create([
$order = new InventoryTransferOrder([
'from_warehouse_id' => $fromWarehouseId,
'to_warehouse_id' => $toWarehouseId,
'transit_warehouse_id' => $transitWarehouseId,
@@ -32,6 +41,26 @@ class TransferService
'remarks' => $remarks,
'created_by' => $userId,
]);
// 手動觸發單號產生邏輯,因為 saveQuietly 繞過了 Model Events
if (empty($order->doc_no)) {
$today = date('Ymd');
$prefix = 'TRF-' . $today . '-';
$lastDoc = InventoryTransferOrder::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';
}
$order->doc_no = $prefix . $nextNumber;
}
$order->saveQuietly();
return $order;
}
/**
@@ -101,6 +130,7 @@ class TransferService
$diff['updated'][] = [
'product_name' => $item->product->name,
'unit_name' => $item->product->baseUnit?->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'position' => $oldItem->position,
@@ -114,12 +144,9 @@ class TransferService
];
}
} else {
$diff['updated'][] = [
$diff['added'][] = [
'product_name' => $item->product->name,
'old' => [
'quantity' => 0,
'notes' => null,
],
'unit_name' => $item->product->baseUnit?->name,
'new' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
@@ -132,6 +159,7 @@ class TransferService
if (!in_array($key, $newItemsKeys)) {
$diff['removed'][] = [
'product_name' => $oldItem->product->name,
'unit_name' => $oldItem->product->baseUnit?->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'notes' => $oldItem->notes,
@@ -169,6 +197,8 @@ class TransferService
$outType = '調撥出庫';
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
$itemsDiff = [];
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
@@ -186,70 +216,65 @@ class TransferService
]);
}
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $item->quantity;
$sourceBefore = (float) $sourceInventory->quantity;
// 釋放草稿階段預扣的庫存
$sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity);
$sourceInventory->saveQuietly();
$item->update(['snapshot_quantity' => $oldSourceQty]);
$sourceInventory->quantity = $newSourceQty;
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
$sourceInventory->save();
$item->update(['snapshot_quantity' => $sourceBefore]);
$sourceInventory->transactions()->create([
'type' => $outType,
'quantity' => -$item->quantity,
'unit_cost' => $sourceInventory->unit_cost,
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "調撥單 {$order->doc_no}{$targetWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 委託 InventoryService 處理扣庫與 Transaction
$this->inventoryService->decreaseInventoryQuantity(
$sourceInventory->id,
$item->quantity,
"調撥單 {$order->doc_no}{$targetWarehouse->name}",
InventoryTransferOrder::class,
$order->id
);
$sourceAfter = $sourceBefore - (float) $item->quantity;
// 2. 處理目的倉/在途倉 (增加)
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $targetWarehouseId,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $hasTransit ? null : ($item->position ?? null),
],
[
'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,
]
);
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
// 獲取目的倉異動前的庫存數(若無則為 0
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $item->quantity;
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
$targetInventory->transactions()->create([
'type' => $inType,
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $targetWarehouseId,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'unit_cost' => $sourceInventory->unit_cost,
'batch_number' => $item->batch_number,
'expiry_date' => $sourceInventory->expiry_date,
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
'reference_type' => InventoryTransferOrder::class,
'reference_id' => $order->id,
'location' => $hasTransit ? null : ($item->position ?? null),
'origin_country' => $sourceInventory->origin_country,
'quality_status' => $sourceInventory->quality_status,
]);
$targetAfter = $targetBefore + (float) $item->quantity;
// 記錄異動明細供整合日誌使用
$itemsDiff[] = [
'product_name' => $item->product->name,
'batch_number' => $item->batch_number,
'quantity' => (float)$item->quantity,
'source_warehouse' => $fromWarehouse->name,
'source_before' => $sourceBefore,
'source_after' => $sourceAfter,
'target_warehouse' => $targetWarehouse->name,
'target_before' => $targetBefore,
'target_after' => $targetAfter,
];
}
$oldStatus = $order->status;
if ($hasTransit) {
$order->status = 'dispatched';
$order->dispatched_at = now();
@@ -259,7 +284,27 @@ class TransferService
$order->posted_at = now();
$order->posted_by = $userId;
}
$order->save();
$order->saveQuietly();
// 手動觸發單一合併日誌
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'items_diff' => $itemsDiff,
'attributes' => [
'status' => $order->status,
'dispatched_at' => $order->dispatched_at ? $order->dispatched_at->format('Y-m-d H:i:s') : null,
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i:s') : null,
'dispatched_by' => $order->dispatched_by,
'posted_by' => $order->posted_by,
],
'old' => [
'status' => $oldStatus,
]
])
->log($order->status == 'completed' ? 'posted' : 'dispatched');
});
}
@@ -283,6 +328,8 @@ class TransferService
$transitWarehouse = $order->transitWarehouse;
$toWarehouse = $order->toWarehouse;
$itemsDiff = [];
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
@@ -299,71 +346,83 @@ class TransferService
]);
}
$oldTransitQty = $transitInventory->quantity;
$newTransitQty = $oldTransitQty - $item->quantity;
$transitBefore = (float) $transitInventory->quantity;
$transitInventory->quantity = $newTransitQty;
$transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost;
$transitInventory->save();
$transitInventory->transactions()->create([
'type' => '在途出庫',
'quantity' => -$item->quantity,
'unit_cost' => $transitInventory->unit_cost,
'balance_before' => $oldTransitQty,
'balance_after' => $newTransitQty,
'reason' => "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 委託 InventoryService 處理扣庫與 Transaction
$this->inventoryService->decreaseInventoryQuantity(
$transitInventory->id,
$item->quantity,
"調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
InventoryTransferOrder::class,
$order->id
);
$transitAfter = $transitBefore - (float) $item->quantity;
// 2. 目的倉增加
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $item->position,
],
[
'quantity' => 0,
'unit_cost' => $transitInventory->unit_cost,
'total_value' => 0,
'expiry_date' => $transitInventory->expiry_date,
'quality_status' => $transitInventory->quality_status,
'origin_country' => $transitInventory->origin_country,
]
);
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $transitInventory->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' => '調撥入庫',
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'unit_cost' => $transitInventory->unit_cost,
'batch_number' => $item->batch_number,
'expiry_date' => $transitInventory->expiry_date,
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
'reference_type' => InventoryTransferOrder::class,
'reference_id' => $order->id,
'location' => $item->position,
'origin_country' => $transitInventory->origin_country,
'quality_status' => $transitInventory->quality_status,
]);
$targetAfter = $targetBefore + (float) $item->quantity;
$itemsDiff[] = [
'product_name' => $item->product->name,
'batch_number' => $item->batch_number,
'quantity' => (float)$item->quantity,
'source_warehouse' => $transitWarehouse->name,
'source_before' => $transitBefore,
'source_after' => $transitAfter,
'target_warehouse' => $toWarehouse->name,
'target_before' => $targetBefore,
'target_after' => $targetAfter,
];
}
$oldStatus = $order->status;
$order->status = 'completed';
$order->posted_at = now();
$order->posted_by = $userId;
$order->received_at = now();
$order->received_by = $userId;
$order->save();
$order->saveQuietly();
// 手動觸發單一合併日誌
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'items_diff' => $itemsDiff,
'attributes' => [
'status' => 'completed',
'posted_at' => $order->posted_at->format('Y-m-d H:i:s'),
'received_at' => $order->received_at->format('Y-m-d H:i:s'),
'posted_by' => $order->posted_by,
'received_by' => $order->received_by,
],
'old' => [
'status' => $oldStatus,
]
])
->log('received');
});
}
@@ -387,10 +446,24 @@ class TransferService
}
}
$order->update([
'status' => 'voided',
'updated_by' => $userId
]);
$oldStatus = $order->status;
$order->status = 'voided';
$order->updated_by = $userId;
$order->saveQuietly();
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => [
'status' => 'voided',
],
'old' => [
'status' => $oldStatus,
]
])
->log('voided');
});
}
}

View File

@@ -104,4 +104,12 @@ interface ProcurementServiceInterface
* 移除供貨商品關聯
*/
public function detachProductFromVendor(int $vendorId, int $productId): void;
/**
* 整批同步供貨商品
*
* @param int $vendorId
* @param array $productsData Format: [['product_id' => 1, 'last_price' => 100], ...]
*/
public function syncVendorProducts(int $vendorId, array $productsData): void;
}

View File

@@ -189,71 +189,81 @@ class PurchaseOrderController extends Controller
]);
try {
DB::beginTransaction();
// 使用 Cache Lock 防止併發時產生重複單號
$lock = \Illuminate\Support\Facades\Cache::lock('po_code_generation', 10);
// 生成單號PO-YYYYMMDD-01
$today = now()->format('Ymd');
$prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 2 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
$sequence = '01';
}
$code = $prefix . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
if (!$lock->get()) {
return back()->withErrors(['error' => '系統忙碌中,請稍後再試']);
}
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
try {
DB::beginTransaction();
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
}
// 生成單號PO-YYYYMMDD-01
$today = now()->format('Ymd');
$prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->orderBy('code', 'desc')
->first();
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'order_date' => $validated['order_date'], // 新增
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
if ($lastOrder) {
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
$sequence = '01';
}
$code = $prefix . $sequence;
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
}
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = $this->coreService->ensureSystemUserExists();
$userId = $user->id;
}
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'order_date' => $validated['order_date'],
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
}
DB::commit();
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
]);
}
DB::commit();
} finally {
$lock->release();
}
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');

View File

@@ -133,4 +133,35 @@ class VendorProductController extends Controller
return redirect()->back()->with('success', '供貨商品已移除');
}
/**
* 整批同步供貨商品
*/
public function sync(Request $request, Vendor $vendor)
{
$validated = $request->validate([
'products' => 'present|array',
'products.*.product_id' => 'required|exists:products,id',
'products.*.last_price' => 'nullable|numeric|min:0',
]);
$this->procurementService->syncVendorProducts($vendor->id, $validated['products']);
activity()
->performedOn($vendor)
->withProperties([
'attributes' => [
'products_count' => count($validated['products']),
],
'sub_subject' => '供貨商品',
'snapshot' => [
'name' => "{$vendor->name} 的供貨清單",
'vendor_name' => $vendor->name,
]
])
->event('updated')
->log('整批更新供貨商品');
return redirect()->back()->with('success', '供貨商品已更新');
}
}

View File

@@ -24,6 +24,9 @@ class PurchaseOrder extends Model
'tax_amount',
'grand_total',
'remark',
'invoice_number',
'invoice_date',
'invoice_amount',
];
protected $casts = [

View File

@@ -16,6 +16,7 @@ Route::middleware('auth')->group(function () {
// 供貨商品相關路由
Route::post('/vendors/{vendor}/products', [VendorProductController::class, 'store'])->middleware('permission:vendors.edit')->name('vendors.products.store');
Route::put('/vendors/{vendor}/products/sync', [VendorProductController::class, 'sync'])->middleware('permission:vendors.edit')->name('vendors.products.sync');
Route::put('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.products.update');
Route::delete('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'destroy'])->middleware('permission:vendors.edit')->name('vendors.products.destroy');
});

View File

@@ -147,4 +147,48 @@ class ProcurementService implements ProcurementServiceInterface
->where('product_id', $productId)
->delete();
}
public function syncVendorProducts(int $vendorId, array $productsData): void
{
\Illuminate\Support\Facades\DB::transaction(function () use ($vendorId, $productsData) {
$existingPivots = \Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->get();
$existingProductIds = $existingPivots->pluck('product_id')->toArray();
$newProductIds = array_column($productsData, 'product_id');
$toDelete = array_diff($existingProductIds, $newProductIds);
if (!empty($toDelete)) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->whereIn('product_id', $toDelete)
->delete();
}
foreach ($productsData as $data) {
$exists = in_array($data['product_id'], $existingProductIds);
if ($exists) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->where('product_id', $data['product_id'])
->update([
'last_price' => $data['last_price'] ?? null,
'updated_at' => now(),
]);
} else {
\Illuminate\Support\Facades\DB::table('product_vendor')
->insert([
'vendor_id' => $vendorId,
'product_id' => $data['product_id'],
'last_price' => $data['last_price'] ?? null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
});
}
}