From 0a955fb9939575e4090f7386891cc129526e018b Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 2 Mar 2026 16:42:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B4=E5=90=88=E9=96=80=E5=B8=82?= =?UTF-8?q?=E9=A0=98=E6=96=99=E6=97=A5=E8=AA=8C=E3=80=81API=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=AD=98=E5=8F=96=E3=80=81=E4=BF=AE=E6=94=B9=E5=BA=AB?= =?UTF-8?q?=E5=AD=98=E8=88=87=E4=BD=B5=E7=99=BC=E7=B7=A8=E8=99=9F=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=E3=80=81=E4=BE=9B=E6=87=89=E5=95=86=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=85=A7=E8=81=AF=E7=B7=A8=E8=BC=AF=E5=8F=8A=E6=97=A5=E8=AA=8C?= =?UTF-8?q?=20UI=20=E5=84=AA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ActivityLogController.php | 1 + .../Controllers/InventoryController.php | 10 +- .../Controllers/ProductController.php | 2 +- .../StoreRequisitionController.php | 9 +- .../Controllers/TransferOrderController.php | 18 ++ .../Inventory/Imports/InventoryImport.php | 3 +- .../Inventory/Imports/ProductImport.php | 105 ++++-- app/Modules/Inventory/Models/Category.php | 17 +- app/Modules/Inventory/Models/GoodsReceipt.php | 41 +++ app/Modules/Inventory/Models/Inventory.php | 4 +- .../Models/InventoryTransferOrder.php | 32 +- .../Inventory/Models/StoreRequisition.php | 77 ++++- app/Modules/Inventory/Models/Unit.php | 17 +- app/Modules/Inventory/Models/Warehouse.php | 21 +- .../Inventory/Services/AdjustService.php | 30 +- .../Services/GoodsReceiptService.php | 147 +++++++-- .../Inventory/Services/InventoryService.php | 22 +- .../Services/StoreRequisitionService.php | 181 ++++++++++- .../Inventory/Services/TransferService.php | 301 +++++++++++------- .../Contracts/ProcurementServiceInterface.php | 8 + .../Controllers/PurchaseOrderController.php | 122 +++---- .../Controllers/VendorProductController.php | 31 ++ .../Procurement/Models/PurchaseOrder.php | 3 + app/Modules/Procurement/Routes/web.php | 1 + .../Services/ProcurementService.php | 44 +++ .../ActivityLog/ActivityDetailDialog.tsx | 200 +++++++----- .../js/Components/ActivityLog/LogTable.tsx | 2 + .../Vendor/AddSupplyProductDialog.tsx | 193 ----------- .../Vendor/EditSupplyProductDialog.tsx | 119 ------- .../Components/Vendor/SupplyProductList.tsx | 155 +++++---- resources/js/Pages/Vendor/Show.tsx | 241 +++++++------- resources/views/docs/api.blade.php | 83 +++++ routes/web.php | 37 +++ 33 files changed, 1424 insertions(+), 853 deletions(-) delete mode 100644 resources/js/Components/Vendor/AddSupplyProductDialog.tsx delete mode 100644 resources/js/Components/Vendor/EditSupplyProductDialog.tsx create mode 100644 resources/views/docs/api.blade.php diff --git a/app/Modules/Core/Controllers/ActivityLogController.php b/app/Modules/Core/Controllers/ActivityLogController.php index 1b4cc3a..6ef7d86 100644 --- a/app/Modules/Core/Controllers/ActivityLogController.php +++ b/app/Modules/Core/Controllers/ActivityLogController.php @@ -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' => '門市叫貨單', ]; } diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index 9c26b5f..a7d58cc 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -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, diff --git a/app/Modules/Inventory/Controllers/ProductController.php b/app/Modules/Inventory/Controllers/ProductController.php index 77a100c..b4c7d8d 100644 --- a/app/Modules/Inventory/Controllers/ProductController.php +++ b/app/Modules/Inventory/Controllers/ProductController.php @@ -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'); } /** diff --git a/app/Modules/Inventory/Controllers/StoreRequisitionController.php b/app/Modules/Inventory/Controllers/StoreRequisitionController.php index e601a7e..c9eb503 100644 --- a/app/Modules/Inventory/Controllers/StoreRequisitionController.php +++ b/app/Modules/Inventory/Controllers/StoreRequisitionController.php @@ -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', '叫貨單已提交審核'); } diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index 8d1d5da..c67afa3 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -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 { diff --git a/app/Modules/Inventory/Imports/InventoryImport.php b/app/Modules/Inventory/Imports/InventoryImport.php index aba1726..56f2f4f 100644 --- a/app/Modules/Inventory/Imports/InventoryImport.php +++ b/app/Modules/Inventory/Imports/InventoryImport.php @@ -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; } diff --git a/app/Modules/Inventory/Imports/ProductImport.php b/app/Modules/Inventory/Imports/ProductImport.php index 227cda5..1ad4710 100644 --- a/app/Modules/Inventory/Imports/ProductImport.php +++ b/app/Modules/Inventory/Imports/ProductImport.php @@ -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 [ + '商品代號(選填)' => '商品代號', + '條碼(選填)' => '條碼', + ]; + } } diff --git a/app/Modules/Inventory/Models/Category.php b/app/Modules/Inventory/Models/Category.php index 130d41f..6100902 100644 --- a/app/Modules/Inventory/Models/Category.php +++ b/app/Modules/Inventory/Models/Category.php @@ -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; } } diff --git a/app/Modules/Inventory/Models/GoodsReceipt.php b/app/Modules/Inventory/Models/GoodsReceipt.php index c4ba032..82bfd9d 100644 --- a/app/Modules/Inventory/Models/GoodsReceipt.php +++ b/app/Modules/Inventory/Models/GoodsReceipt.php @@ -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); diff --git a/app/Modules/Inventory/Models/Inventory.php b/app/Modules/Inventory/Models/Inventory.php index bf1fbf5..d09e9c2 100644 --- a/app/Modules/Inventory/Models/Inventory.php +++ b/app/Modules/Inventory/Models/Inventory.php @@ -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(); } /** diff --git a/app/Modules/Inventory/Models/InventoryTransferOrder.php b/app/Modules/Inventory/Models/InventoryTransferOrder.php index 82769b1..1a05018 100644 --- a/app/Modules/Inventory/Models/InventoryTransferOrder.php +++ b/app/Modules/Inventory/Models/InventoryTransferOrder.php @@ -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); } diff --git a/app/Modules/Inventory/Models/StoreRequisition.php b/app/Modules/Inventory/Models/StoreRequisition.php index 314ead4..c0a44d0 100644 --- a/app/Modules/Inventory/Models/StoreRequisition.php +++ b/app/Modules/Inventory/Models/StoreRequisition.php @@ -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); } diff --git a/app/Modules/Inventory/Models/Unit.php b/app/Modules/Inventory/Models/Unit.php index c1828a0..56b630c 100644 --- a/app/Modules/Inventory/Models/Unit.php +++ b/app/Modules/Inventory/Models/Unit.php @@ -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; } } diff --git a/app/Modules/Inventory/Models/Warehouse.php b/app/Modules/Inventory/Models/Warehouse.php index b70f749..838373c 100644 --- a/app/Modules/Inventory/Models/Warehouse.php +++ b/app/Modules/Inventory/Models/Warehouse.php @@ -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; } diff --git a/app/Modules/Inventory/Services/AdjustService.php b/app/Modules/Inventory/Services/AdjustService.php index b5410ef..47bcfbc 100644 --- a/app/Modules/Inventory/Services/AdjustService.php +++ b/app/Modules/Inventory/Services/AdjustService.php @@ -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, ]); } diff --git a/app/Modules/Inventory/Services/GoodsReceiptService.php b/app/Modules/Inventory/Services/GoodsReceiptService.php index 6aa12aa..ce34971 100644 --- a/app/Modules/Inventory/Services/GoodsReceiptService.php +++ b/app/Modules/Inventory/Services/GoodsReceiptService.php @@ -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(); + } } /** diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index edc9931..e43378c 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -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(); } }); } diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php index b1e12f4..0cf0e80 100644 --- a/app/Modules/Inventory/Services/StoreRequisitionService.php +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -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'); } // 更新叫貨單狀態 diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index 8403c30..a2c106b 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -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'); }); } } diff --git a/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php index d8f17a7..fe94eae 100644 --- a/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php +++ b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php @@ -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; } diff --git a/app/Modules/Procurement/Controllers/PurchaseOrderController.php b/app/Modules/Procurement/Controllers/PurchaseOrderController.php index 348c38b..c69dc44 100644 --- a/app/Modules/Procurement/Controllers/PurchaseOrderController.php +++ b/app/Modules/Procurement/Controllers/PurchaseOrderController.php @@ -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', '採購單已成功建立'); diff --git a/app/Modules/Procurement/Controllers/VendorProductController.php b/app/Modules/Procurement/Controllers/VendorProductController.php index b073bc7..254dd0d 100644 --- a/app/Modules/Procurement/Controllers/VendorProductController.php +++ b/app/Modules/Procurement/Controllers/VendorProductController.php @@ -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', '供貨商品已更新'); + } } diff --git a/app/Modules/Procurement/Models/PurchaseOrder.php b/app/Modules/Procurement/Models/PurchaseOrder.php index d89e54b..0f0ded7 100644 --- a/app/Modules/Procurement/Models/PurchaseOrder.php +++ b/app/Modules/Procurement/Models/PurchaseOrder.php @@ -24,6 +24,9 @@ class PurchaseOrder extends Model 'tax_amount', 'grand_total', 'remark', + 'invoice_number', + 'invoice_date', + 'invoice_amount', ]; protected $casts = [ diff --git a/app/Modules/Procurement/Routes/web.php b/app/Modules/Procurement/Routes/web.php index f4f0ef5..73c7382 100644 --- a/app/Modules/Procurement/Routes/web.php +++ b/app/Modules/Procurement/Routes/web.php @@ -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'); }); diff --git a/app/Modules/Procurement/Services/ProcurementService.php b/app/Modules/Procurement/Services/ProcurementService.php index a3a12d3..6fc6427 100644 --- a/app/Modules/Procurement/Services/ProcurementService.php +++ b/app/Modules/Procurement/Services/ProcurementService.php @@ -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(), + ]); + } + } + }); + } } diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 2c1abfa..f32cdfd 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -168,6 +168,28 @@ const fieldLabels: Record = { posted_by: '過帳者', counted_qty: '盤點數量', adjust_qty: '調整數量', + // 調撥單專有欄位 + transit_warehouse_id: '在途倉庫', + transit_warehouse_name: '在途倉庫名稱', + dispatched_at: '出貨日期', + dispatched_by: '出貨人', + received_at: '收貨日期', + received_by: '收貨人', + reserved_quantity: '預扣數量', + snapshot_quantity: '異動前庫存 (快照)', + // 門市叫貨欄位 + store_warehouse_id: '申請倉庫', + store_warehouse_name: '申請倉庫', + supply_warehouse_id: '供貨倉庫', + supply_warehouse_name: '供貨倉庫', + approved_by: '審核人', + approved_user_name: '審核人', + approved_at: '審核時間', + submitted_at: '提交時間', + reject_reason: '駁回原因', + status_label: '處理狀態', + transfer_order_id: '調撥單 ID', + transfer_order_name: '調撥單號', }; // 狀態翻譯對照表 @@ -192,6 +214,7 @@ const statusMap: Record = { in_progress: '生產中', // 調撥單狀態 voided: '已作廢', + dispatched: '已出貨', }; // 主體類型解析 (Model 類名轉中文) @@ -206,17 +229,7 @@ const subjectTypeMap: Record = { 'App\\Modules\\Inventory\\Models\\GoodsReceipt': '進貨單', 'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單', 'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單', - 'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單', - 'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據', - 'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料', - 'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單', - 'App\\Modules\\Production\\Models\\ProductionOrder': '生產工單', - 'App\\Modules\\Production\\Models\\Recipe': '生產配方', - 'App\\Modules\\Production\\Models\\RecipeItem': '配方品項', - 'App\\Modules\\Production\\Models\\ProductionOrderItem': '工單品項', - 'App\\Modules\\Finance\\Models\\UtilityFee': '公共事業費', - 'App\\Modules\\Core\\Models\\User': '使用者帳號', - 'App\\Modules\\Core\\Models\\Role': '角色權限', + 'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單', // 簡寫映射 (應對後端回傳 class_basename 的情況) 'Product': '商品資料', 'Warehouse': '倉庫資料', @@ -231,7 +244,8 @@ const subjectTypeMap: Record = { 'Recipe': '生產配方', 'InventoryCountDoc': '庫存盤點單', 'InventoryAdjustDoc': '庫存盤調單', - 'InventoryTransferOrder': '庫存調撥單', + 'InventoryTransferOrder': '庫庫調撥單', + 'StoreRequisition': '門市叫貨單', 'StockMovementDoc': '庫存單據', 'User': '使用者帳號', 'Role': '角色權限', @@ -264,17 +278,31 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P // 自訂欄位排序順序 const sortOrder = [ - 'doc_no', 'po_number', 'gr_number', 'production_number', + 'doc_no', 'po_number', 'gr_number', 'production_number', 'transfer_order_name', 'vendor_name', 'warehouse_name', 'order_date', 'expected_delivery_date', 'status', 'remark', 'invoice_number', 'invoice_date', 'invoice_amount', 'total_amount', 'tax_amount', 'grand_total' ]; - // 過濾掉通常會記錄但對使用者無用的內部鍵 + // 過濾掉通常會記錄但對使用者無用的內部鍵,以及已被解析為名稱的原始 ID 欄位 const filteredKeys = allKeys - .filter(key => - !['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key) - ) + .filter(key => { + if (['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token', 'activityProperties'].includes(key)) return false; + + // 隱藏冗餘的狀態標籤 (因為後端已統一替換 status 內容) + if (key === 'status_label') return false; + + // 隱藏技術用的 ID 欄位 (如果已有對應的名稱欄位) + if (key.endsWith('_id')) { + const nameKey = key.replace('_id', '_name'); + const userNameKey = key.replace('_id', '_user_name'); + if (allKeys.includes(nameKey) || allKeys.includes(userNameKey)) return false; + } + // 特別隱藏調撥單 ID + if (key === 'transfer_order_id' && (allKeys.includes('transfer_order_name') || allKeys.includes('transfer_order_doc_no'))) return false; + + return true; + }) .sort((a, b) => { const indexA = sortOrder.indexOf(a); const indexB = sortOrder.indexOf(b); @@ -336,9 +364,13 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P if ((key === 'order_date' || key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date' || key === 'received_date' || key === 'production_date') && typeof value === 'string') { return value.split('T')[0].split(' ')[0]; } - if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at' || key === 'created_at' || key === 'updated_at' || key === 'actual_time') && typeof value === 'string') { + if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at' || key === 'created_at' || key === 'updated_at' || key === 'actual_time' || key === 'submitted_at' || key === 'approved_at') && typeof value === 'string') { try { - const date = new Date(value); + // 處理部分 ISO 字串包含 T 的情況 + const normalizedValue = value.replace('T', ' '); + const date = new Date(normalizedValue); + if (isNaN(date.getTime())) return value; + return date.toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', year: 'numeric', @@ -537,65 +569,89 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P - {/* 更新項目 */} - {activity.properties.items_diff.updated?.map((item: any, idx: number) => ( - - {item.product_name} - - 更新 + {/* 1. 處理物件格式的 items_diff (現有的採購、盤點、調撥初始紀錄格式) */} + {!Array.isArray(activity.properties?.items_diff) && ( + <> + {/* 更新項目 */} + {activity.properties?.items_diff?.updated?.map((item: any, idx: number) => ( + + {item.product_name} + + 更新 + + +
+ {item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && ( +
數量: {item.old.quantity}{item.new.quantity}
+ )} + {item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && ( +
盤點量: {item.old.counted_qty ?? '未盤'}{item.new.counted_qty ?? '未盤'}
+ )} +
+
+
+ )) || null} + + {/* 新增項目 */} + {activity.properties?.items_diff?.added?.map((item: any, idx: number) => ( + + {item.product_name} + + 新增 + + + 數量: {item.new?.quantity ?? item.quantity} {item.unit_name || item.new?.unit_name || ''} + + + )) || null} + + {/* 移除項目 */} + {activity.properties?.items_diff?.removed?.map((item: any, idx: number) => ( + + {item.product_name} + + 移除 + + + 原數量: {item.old?.quantity ?? item.quantity} {item.unit_name || item.old?.unit_name || ''} + + + )) || null} + + )} + + {/* 2. 處理陣列格式的 items_diff (調撥單過帳/收貨的複合紀錄格式) */} + {Array.isArray(activity.properties?.items_diff) && activity.properties.items_diff.map((item: any, idx: number) => ( + + + {item.product_name} +
批號: {item.batch_number}
- -
- {item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && ( -
數量: {item.old.quantity}{item.new.quantity}
+ + 異動 + + +
+ {item.source_warehouse && ( +
+ 來源 ({item.source_warehouse}): + {Number(item.source_before || 0).toFixed(0)} + → {Number(item.source_after || 0).toFixed(0)} + -{item.quantity} +
)} - {item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && ( -
盤點量: {item.old.counted_qty ?? '未盤'}{item.new.counted_qty ?? '未盤'}
- )} - {item.old?.adjust_qty !== item.new?.adjust_qty && ( -
調整量: {item.old?.adjust_qty ?? '0'}{item.new?.adjust_qty ?? '0'}
- )} - {item.old?.unit_name !== item.new?.unit_name && item.old?.unit_name !== undefined && ( -
單位: {item.old.unit_name || '-'}{item.new.unit_name || '-'}
- )} - {item.old?.subtotal !== item.new?.subtotal && item.old?.subtotal !== undefined && ( -
小計: ${item.old.subtotal}${item.new.subtotal}
- )} - {item.old?.notes !== item.new?.notes && ( -
備註: {item.old?.notes || '-'}{item.new?.notes || '-'}
+ {item.target_warehouse && ( +
+ 目的 ({item.target_warehouse}): + {Number(item.target_before || 0).toFixed(0)} + → {Number(item.target_after || 0).toFixed(0)} + +{item.quantity} +
)}
- )) || null} - - {/* 新增項目 */} - {activity.properties.items_diff.added?.map((item: any, idx: number) => ( - - {item.product_name} - - 新增 - - - {item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''} - {item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''} - {item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''} - - - )) || null} - - {/* 移除項目 */} - {activity.properties.items_diff.removed?.map((item: any, idx: number) => ( - - {item.product_name} - - 移除 - - - 原紀錄: {item.quantity} {item.unit_name} - - - )) || null} + ))}
diff --git a/resources/js/Components/ActivityLog/LogTable.tsx b/resources/js/Components/ActivityLog/LogTable.tsx index 00bc9f2..a95a7bd 100644 --- a/resources/js/Components/ActivityLog/LogTable.tsx +++ b/resources/js/Components/ActivityLog/LogTable.tsx @@ -42,6 +42,7 @@ const subjectTypeMap: Record = { 'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單', 'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單', 'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單', + 'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單', 'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據', 'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料', 'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單', @@ -67,6 +68,7 @@ const subjectTypeMap: Record = { 'InventoryCountDoc': '庫存盤點單', 'InventoryAdjustDoc': '庫存盤調單', 'InventoryTransferOrder': '庫存調撥單', + 'StoreRequisition': '門市叫貨單', 'StockMovementDoc': '庫存單據', 'User': '使用者帳號', 'Role': '角色權限', diff --git a/resources/js/Components/Vendor/AddSupplyProductDialog.tsx b/resources/js/Components/Vendor/AddSupplyProductDialog.tsx deleted file mode 100644 index d6fd911..0000000 --- a/resources/js/Components/Vendor/AddSupplyProductDialog.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * 新增供貨商品對話框 - */ - -import { useState, useMemo } from "react"; -import { Button } from "@/Components/ui/button"; -import { Input } from "@/Components/ui/input"; -import { Label } from "@/Components/ui/label"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/Components/ui/dialog"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/Components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/Components/ui/popover"; -import { Check, ChevronsUpDown } from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { Product } from "@/types/product"; -import type { SupplyProduct } from "@/types/vendor"; - -interface AddSupplyProductDialogProps { - open: boolean; - products: Product[]; - existingSupplyProducts: SupplyProduct[]; - onClose: () => void; - onAdd: (productId: string, lastPrice?: number) => void; -} - -export default function AddSupplyProductDialog({ - open, - products, - existingSupplyProducts, - onClose, - onAdd, -}: AddSupplyProductDialogProps) { - const [selectedProductId, setSelectedProductId] = useState(""); - const [lastPrice, setLastPrice] = useState(""); - const [openCombobox, setOpenCombobox] = useState(false); - - // 過濾掉已經在供貨列表中的商品 - const availableProducts = useMemo(() => { - const existingIds = new Set(existingSupplyProducts.map(sp => String(sp.productId))); - return products.filter(p => !existingIds.has(String(p.id))); - }, [products, existingSupplyProducts]); - - const selectedProduct = availableProducts.find(p => p.id === selectedProductId); - - const handleAdd = () => { - if (!selectedProductId) return; - - const price = lastPrice ? parseFloat(lastPrice) : undefined; - onAdd(selectedProductId, price); - - // 重置表單 - setSelectedProductId(""); - setLastPrice(""); - }; - - const handleCancel = () => { - setSelectedProductId(""); - setLastPrice(""); - onClose(); - }; - - return ( - - - - 新增供貨商品 - 選擇該廠商可供應的商品並設定採購價格。 - - -
- {/* 商品選擇 */} -
- - - - - - - - - - - 找不到符合的商品 - - - {availableProducts.map((product) => ( - { - setSelectedProductId(product.id); - setOpenCombobox(false); - }} - className="cursor-pointer aria-selected:bg-primary/5 aria-selected:text-primary py-3" - > - -
- {product.name} - - {product.baseUnit?.name || (product.base_unit as any)?.name || product.base_unit || "個"} - -
-
- ))} -
-
-
-
-
-
- - {/* 單位(自動帶入) */} -
- -
- {selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "-"} -
-
- - {/* 上次採購價格 */} -
- - setLastPrice(e.target.value)} - className="mt-1" - /> -
-
- - - - - -
-
- ); -} diff --git a/resources/js/Components/Vendor/EditSupplyProductDialog.tsx b/resources/js/Components/Vendor/EditSupplyProductDialog.tsx deleted file mode 100644 index 1aa3fc6..0000000 --- a/resources/js/Components/Vendor/EditSupplyProductDialog.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * 編輯供貨商品對話框 - */ - -import { useEffect, useState } from "react"; -import { Button } from "@/Components/ui/button"; -import { Input } from "@/Components/ui/input"; -import { Label } from "@/Components/ui/label"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/Components/ui/dialog"; -import type { SupplyProduct } from "@/types/vendor"; - -interface EditSupplyProductDialogProps { - open: boolean; - product: SupplyProduct | null; - onClose: () => void; - onSave: (productId: string, lastPrice?: number) => void; -} - -export default function EditSupplyProductDialog({ - open, - product, - onClose, - onSave, -}: EditSupplyProductDialogProps) { - const [lastPrice, setLastPrice] = useState(""); - - useEffect(() => { - if (product) { - setLastPrice(product.lastPrice?.toString() || ""); - } - }, [product, open]); - - const handleSave = () => { - if (!product) return; - - const price = lastPrice ? parseFloat(lastPrice) : undefined; - onSave(product.productId, price); - setLastPrice(""); - }; - - const handleCancel = () => { - setLastPrice(""); - onClose(); - }; - - if (!product) return null; - - return ( - - - - 編輯供貨商品 - 修改商品的採購價格資訊。 - - -
- {/* 商品名稱(不可編輯) */} -
- - -
- - {/* 單位(不可編輯) */} -
- - -
- - {/* 上次採購價格 */} -
- - setLastPrice(e.target.value)} - className="mt-1" - /> -
-
- - - - - -
-
- ); -} diff --git a/resources/js/Components/Vendor/SupplyProductList.tsx b/resources/js/Components/Vendor/SupplyProductList.tsx index 1b52482..6a768c0 100644 --- a/resources/js/Components/Vendor/SupplyProductList.tsx +++ b/resources/js/Components/Vendor/SupplyProductList.tsx @@ -1,5 +1,7 @@ -import { Pencil, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Table, TableBody, @@ -8,90 +10,129 @@ import { TableHeader, TableRow, } from "@/Components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/Components/ui/alert-dialog"; import type { SupplyProduct } from "@/types/vendor"; interface SupplyProductListProps { - products: SupplyProduct[]; - onEdit: (product: SupplyProduct) => void; - onRemove: (product: SupplyProduct) => void; + items: SupplyProduct[]; + allProducts: any[]; + onRemoveItem: (index: number) => void; + onItemChange: (index: number, field: keyof SupplyProduct, value: string | number) => void; } export default function SupplyProductList({ - products, - onEdit, - onRemove, + items, + allProducts, + onRemoveItem, + onItemChange, }: SupplyProductListProps) { return ( -
+
- + # - 商品名稱 - 基本單位 - 轉換率 - + 商品名稱 + 基本單位 + 上次採購單價 -
(以基本單位計算)
+ (以基本單位計)
- 操作 + 操作
- {products.length === 0 ? ( + {items.length === 0 ? ( - - 尚無供貨商品,請點擊上方按鈕新增 + + 尚未新增任何供貨商品,點擊上方按鈕新增 ) : ( - products.map((product, index) => ( - + items.map((item, index) => ( + {index + 1} - {product.productName} + - {product.baseUnit || product.unit || "-"} + onItemChange(index, "productId", value)} + options={allProducts.map(p => ({ + label: p.name, + value: String(p.id) + }))} + placeholder="選擇商品" + searchPlaceholder="搜尋商品..." + className="w-full h-10" + /> + - {product.largeUnit && product.conversionRate ? ( - - 1 {product.largeUnit} = {Number(product.conversionRate)} {product.baseUnit || product.unit} - - ) : ( - "-" - )} - - - {product.lastPrice ? ( - - ${product.lastPrice.toLocaleString()} / {product.baseUnit || product.unit || "單位"} - - ) : ( - "-" - )} - - -
- - +
+ {item.baseUnit || "-"}
+ + +
+
+ $ + onItemChange(index, "lastPrice", e.target.value === "" ? 0 : Number(e.target.value))} + placeholder="0.00" + className="pl-6 h-10 w-full text-right" + /> +
+
+
+ + + + + + + + + 確定要移除此商品嗎? + + 此動作將從待更新清單中移除「{item.productName || "此商品"}」。需點擊下方的「更新供貨商品」才會正式生效。 + + + + 取消 + onRemoveItem(index)} + className="bg-red-600 hover:bg-red-700 text-white" + > + 確定移除 + + + + + )) )} diff --git a/resources/js/Pages/Vendor/Show.tsx b/resources/js/Pages/Vendor/Show.tsx index 413cac4..a0a3370 100644 --- a/resources/js/Pages/Vendor/Show.tsx +++ b/resources/js/Pages/Vendor/Show.tsx @@ -1,29 +1,14 @@ -/** - * 廠商詳細資訊頁面 - */ - -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Head, Link, router } from "@inertiajs/react"; import { Phone, Mail, Plus, ArrowLeft, Contact2 } from "lucide-react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Label } from "@/Components/ui/label"; import { Button } from "@/Components/ui/button"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/Components/ui/alert-dialog"; import SupplyProductList from "@/Components/Vendor/SupplyProductList"; -import AddSupplyProductDialog from "@/Components/Vendor/AddSupplyProductDialog"; -import EditSupplyProductDialog from "@/Components/Vendor/EditSupplyProductDialog"; import type { Vendor } from "@/Pages/Vendor/Index"; import type { SupplyProduct } from "@/types/vendor"; import { getShowBreadcrumbs } from "@/utils/breadcrumb"; +import { toast } from "sonner"; interface Pivot { last_price: number | null; @@ -33,12 +18,11 @@ interface VendorProduct { id: number; name: string; unit?: string; - // Relations might be camelCase or snake_case depending on serialization settings baseUnit?: { name: string }; base_unit?: { name: string }; largeUnit?: { name: string }; large_unit?: { name: string }; - purchaseUnit?: string; // Note: if it's a relation it might be an object, but original code treated it as string + purchaseUnit?: string; purchase_unit?: string; conversion_rate?: number; pivot: Pivot; @@ -53,78 +37,102 @@ interface ShowProps { products: any[]; } -export default function VendorShow({ vendor, products }: ShowProps) { - const [showAddDialog, setShowAddDialog] = useState(false); - const [showEditDialog, setShowEditDialog] = useState(false); - const [showRemoveDialog, setShowRemoveDialog] = useState(false); - const [selectedProduct, setSelectedProduct] = useState(null); +export default function VendorShow({ vendor, products: allProducts }: ShowProps) { + const [items, setItems] = useState([]); - // 轉換後端資料格式為前端組件需要的格式 - const supplyProducts: SupplyProduct[] = vendor.products.map(p => { - // Laravel load('relationName') usually results in camelCase key in JSON if method is camelCase - const baseUnitName = p.baseUnit?.name || p.base_unit?.name; - const largeUnitName = p.largeUnit?.name || p.large_unit?.name; + // 初始化資料 + useEffect(() => { + const initialItems: SupplyProduct[] = vendor.products.map(p => { + const baseUnitName = p.baseUnit?.name || p.base_unit?.name; + const largeUnitName = p.largeUnit?.name || p.large_unit?.name; - // Check purchase unit - seemingly originally a field string, but if relation, check if object - // Assuming purchase_unit is a string field on product table here based on original code usage? - // Wait, original code usage: p.purchase_unit || ... - // In Product model: purchase_unit_id exists, purchaseUnit is relation. - // If p.purchase_unit was working before, it might be an attribute (accessors). - // Let's stick to safe access. - - return { - id: String(p.id), - productId: String(p.id), - productName: p.name, - unit: p.purchase_unit || baseUnitName || "個", - baseUnit: baseUnitName, - largeUnit: largeUnitName, - conversionRate: p.conversion_rate, - lastPrice: p.pivot.last_price || undefined, - }; - }); - - const handleAddProduct = (productId: string, lastPrice?: number) => { - router.post(route('vendors.products.store', vendor.id), { - product_id: productId, - last_price: lastPrice, - }, { - onSuccess: () => setShowAddDialog(false), + return { + id: String(p.id) + "_" + Math.random().toString(36).substr(2, 9), // 加上隨機碼以確保 key 唯一 + productId: String(p.id), + productName: p.name, + unit: p.purchase_unit || baseUnitName || "個", + baseUnit: baseUnitName, + largeUnit: largeUnitName, + conversionRate: p.conversion_rate, + lastPrice: p.pivot.last_price || undefined, + }; }); + setItems(initialItems); + }, [vendor.products]); + + const handleAddItem = () => { + const newItem: SupplyProduct = { + id: "new_" + Math.random().toString(36).substr(2, 9), + productId: "", + productName: "", + unit: "個", + lastPrice: undefined, + }; + setItems([...items, newItem]); }; - const handleEditProduct = (product: SupplyProduct) => { - setSelectedProduct(product); - setShowEditDialog(true); + const handleRemoveItem = (index: number) => { + const newItems = [...items]; + newItems.splice(index, 1); + setItems(newItems); }; - const handleUpdateProduct = (productId: string, lastPrice?: number) => { - router.put(route('vendors.products.update', [vendor.id, productId]), { - last_price: lastPrice, + const handleItemChange = (index: number, field: keyof SupplyProduct, value: any) => { + const newItems = [...items]; + const item = { ...newItems[index] }; + + if (field === "productId") { + const product = allProducts.find(p => String(p.id) === String(value)); + if (product) { + item.productId = String(product.id); + item.productName = product.name; + item.baseUnit = product.baseUnit?.name || product.base_unit?.name || product.base_unit || "個"; + item.largeUnit = product.largeUnit?.name || product.large_unit?.name; + item.conversionRate = product.conversion_rate; + item.unit = item.baseUnit || "個"; + } else { + item.productId = value; + item.productName = ""; + } + } else { + (item as any)[field] = value; + } + + newItems[index] = item; + setItems(newItems); + }; + + const handleSaveAll = () => { + // 過濾掉沒有選擇商品的項目 + const validItems = items.filter(item => item.productId !== ""); + + if (validItems.length === 0 && items.length > 0) { + toast.error("請至少選擇一個有效的商品,或移除空白列"); + return; + } + + // 檢查重複商品 + const productIds = validItems.map(i => i.productId); + if (new Set(productIds).size !== productIds.length) { + toast.error("供貨清單中有重複的商品,請檢查"); + return; + } + + router.put(route('vendors.products.sync', vendor.id), { + products: validItems.map(item => ({ + product_id: item.productId, + last_price: item.lastPrice, + })) }, { onSuccess: () => { - setShowEditDialog(false); - setSelectedProduct(null); + toast.success("供貨商品已更新"); + }, + onError: () => { + toast.error("更新失敗,請檢查欄位格式"); } }); }; - const handleRemoveProduct = (product: SupplyProduct) => { - setSelectedProduct(product); - setShowRemoveDialog(true); - }; - - const handleConfirmRemove = () => { - if (selectedProduct) { - router.delete(route('vendors.products.destroy', [vendor.id, selectedProduct.productId]), { - onSuccess: () => { - setShowRemoveDialog(false); - setSelectedProduct(null); - } - }); - } - }; - return ( @@ -164,11 +172,11 @@ export default function VendorShow({ vendor, products }: ShowProps) {
-

{vendor.short_name || "-"}

+

{vendor.shortName || "-"}

-

{vendor.tax_id || "-"}

+

{vendor.taxId || "-"}

@@ -187,7 +195,7 @@ export default function VendorShow({ vendor, products }: ShowProps) {
-

{vendor.contact_name || "-"}

+

{vendor.contactName || "-"}

@@ -212,9 +220,9 @@ export default function VendorShow({ vendor, products }: ShowProps) { {/* 供貨商品列表 */}
-

供貨商品

+

供貨商品

+
- {/* 新增供貨商品對話框 */} - setShowAddDialog(false)} - onAdd={handleAddProduct} - /> - - {/* 編輯供貨商品對話框 */} - { - setShowEditDialog(false); - setSelectedProduct(null); - }} - onSave={handleUpdateProduct} - /> - - {/* 取消供貨確認對話框 */} - - - - 確認取消供貨 - - 確定要將「{selectedProduct?.productName}」從供貨列表中移除嗎?此操作無法撤銷。 - - - - - 取消 - - - 確認移除 - - - - + {/* 底部按鈕 - 移至容器外 */} +
+ + + + +
); diff --git a/resources/views/docs/api.blade.php b/resources/views/docs/api.blade.php new file mode 100644 index 0000000..39ff6b4 --- /dev/null +++ b/resources/views/docs/api.blade.php @@ -0,0 +1,83 @@ + + + + + + {{ $title ?? 'API Documentation' }} - Star ERP + + + + + + + +
+ + + + +
+
+
+ {!! $content !!} +
+ +
+ © {{ date('Y') }} Star ERP System. All rights reserved. + 整合介面版本 v1.2 +
+
+
+
+ + +
+ +
+ + diff --git a/routes/web.php b/routes/web.php index a1cbbdc..0f1a65c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,43 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; // 登入/登出路由 +Route::get('/api/docs', function () { + $path = resource_path('markdown/manual/api-integration.md'); + + if (!file_exists($path)) { + abort(404); + } + + $markdown = file_get_contents($path); + + // 解析 Markdown 內容 + $content = Illuminate\Support\Str::markdown($markdown, [ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + // 萃取 TOC (簡單的正則表達式抓取 ## 標題) + preg_match_all('/^##\s+(.+)$/m', $markdown, $matches); + $toc = collect($matches[1])->map(function ($text) { + return [ + 'id' => Illuminate\Support\Str::slug($text), + 'text' => $text, + ]; + }); + + // 替換內容中的 ## 標題以加入 ID 錨點 (讓左側導覽能跳轉) + foreach ($toc as $item) { + $content = str_replace("

{$item['text']}

", "

{$item['text']}

", $content); + } + + return view('docs.api', [ + 'content' => $content, + 'toc' => $toc, + 'title' => '外部系統 API 對接手冊' + ]); +}); + + Route::middleware('auth')->group(function () { // 儀表板 - 所有登入使用者皆可存取