Compare commits
195 Commits
feature/mo
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba6b3a1cd | |||
| ec239279f4 | |||
| e2c36e9c0f | |||
| 30bf8ef79c | |||
| 590d1ea9e9 | |||
| cd0f454c98 | |||
| 54e1e5df5a | |||
| 8e3d951d0d | |||
| d04e5bbffb | |||
| 27273bfee4 | |||
| 2a88649f75 | |||
| e9313158ba | |||
| f3da49a76a | |||
| 747f70865d | |||
| 6bb2afa3b7 | |||
| 59008eb59e | |||
| a33e470e4d | |||
| 71b676b533 | |||
| 406d03297a | |||
| 4259c7745b | |||
| 8169ff3f59 | |||
| 1acc4daebb | |||
| 1acbfb7246 | |||
| e02d7c7125 | |||
| a133b94a05 | |||
| acd0590a38 | |||
| a2fe7b5a95 | |||
| 5f1f08869f | |||
| e85c1fa95a | |||
| 62dcf04e95 | |||
| 6dd3396fb7 | |||
| 2f30a78118 | |||
| 904132e460 | |||
| a05acd96dc | |||
| 29cdf37b71 | |||
| d7d1be81a9 | |||
| 227cfec0d2 | |||
| 034a21cd31 | |||
| 6358e23816 | |||
| ac149533f0 | |||
| b20a47f710 | |||
| d017d7e5e0 | |||
| 8207e6fe94 | |||
| e6cf03b991 | |||
| 8ef82d49cb | |||
| bb2cf77ccb | |||
| d543e6e810 | |||
| 64e039cc71 | |||
| cce8dd3c8b | |||
| 6a0f57c86c | |||
| 0251540365 | |||
| ab5b4bde0b | |||
| f85f06f3e1 | |||
| 6671e4221f | |||
| 24f73a2585 | |||
| 2e9ff6c832 | |||
| 77a7d31dc1 | |||
| e141a45eb9 | |||
| 4fa87925a2 | |||
| 097708aab7 | |||
| b8cbf0bb6d | |||
| 882091ce5f | |||
| 245553280a | |||
| 299602d3b1 | |||
| 96f2ccee95 | |||
| c9113544ee | |||
| 5be4d49679 | |||
| b118ea0c39 | |||
| eb5ab58093 | |||
| 57e633c3e9 | |||
| 448b37ca90 | |||
| 6c146ac717 | |||
| cb433035fe | |||
| e646c6ffd8 | |||
| 83e1c82b11 | |||
| 19397db2e9 | |||
| db285a6b69 | |||
| 74eeb449f8 | |||
| 28ece9fda4 | |||
| bd292b0868 | |||
| ac705a1e58 | |||
| 936abc943e | |||
| eabde37d15 | |||
| 921f6e48fb | |||
| ba4ceb7ff6 | |||
| 3be5d099c9 | |||
| 9537e48f08 | |||
| 165737750c | |||
| 220478641d | |||
| 593ce94734 | |||
| 8b950f6529 | |||
| e098e40fb8 | |||
| 83d26de6f9 | |||
| 38642cc58b | |||
| a6393e03d8 | |||
| 6980eac1a4 | |||
| 08e360464e | |||
| 7cf640b2f4 | |||
| 613eb555ba | |||
| 65eb1a1b64 | |||
| b6fe9ad9f3 | |||
| 590580e20a | |||
| c2e0ff726d | |||
| 5e542752ba | |||
| f22df90e01 | |||
| e018b75783 | |||
| 200d1989bd | |||
| 6c259859cf | |||
| 6bfdd92347 | |||
| 70f1709bd0 | |||
| 3fd333085b | |||
| 906b094c18 | |||
| e1aa452b3c | |||
| 397a8a6484 | |||
| 24aed44cd3 | |||
| 196fec3120 | |||
| 096a114457 | |||
| af06ca7695 | |||
| 1d5bc68444 | |||
| 075b9f1c98 | |||
| 49bb05d85a | |||
| 687af254bd | |||
| a518d390bd | |||
| ba3c10ac13 | |||
| dada3a6512 | |||
| b99e391cc6 | |||
| 0aa7fd1f75 | |||
| 3ce96537b3 | |||
| 04f3891275 | |||
| 4299e985e9 | |||
| 2eb136d280 | |||
| 88415505fb | |||
| 702af0a259 | |||
| f4f597e96d | |||
| a8b88b3375 | |||
| 95fdec8a06 | |||
| 4ba85ce446 | |||
| a0c450d229 | |||
| 16967fc25d | |||
| 29842510c4 | |||
| 19216f5846 | |||
| bd999c7bb6 | |||
| 15aaa039e4 | |||
| 27626e6aa8 | |||
| a160e3f15f | |||
| d671c08338 | |||
| 0185843c62 | |||
| be5c121146 | |||
| f87310e707 | |||
| b0192e9b66 | |||
| 8a34aae312 | |||
| 6204f0d915 | |||
| df3db38dd4 | |||
| 75c634ffe4 | |||
| 1748eb007e | |||
| 313b95ceb9 | |||
| 5e897e4197 | |||
| 71458dd976 | |||
| 36ef411975 | |||
| bb78a432f5 | |||
| 0d720f3515 | |||
| 2e71a1cb29 | |||
| 746eeb6f01 | |||
| 7619dc24f7 | |||
| 2efaded77b | |||
| a31c8d6052 | |||
| 56e30a85bb | |||
| 46753cc3bc | |||
| 7f726e80bd | |||
| 8bc95db43d | |||
| 95a1763d04 | |||
| 90cb7a82de | |||
| bbb2c4c4a3 | |||
| 8cb95e1a56 | |||
| fc59c86305 | |||
| b613cdb796 | |||
| b1745555cc | |||
| 1833ca192d | |||
| e5edad4fd0 | |||
| 852370cfe0 | |||
| 965418077b | |||
| c3af92c85c | |||
| cca49b5fe8 | |||
| d4cef2cd84 | |||
| 4c959efc8b | |||
| 95d8dc2e84 | |||
| a7c445bd3f | |||
| 293358df62 | |||
| 1ed3d6a29d | |||
| 646435f87a | |||
| f10c31abd0 | |||
| 046e0a028b | |||
| ce0a7b3409 | |||
| 084bbc9f53 | |||
| 3af4a1e298 |
@@ -50,7 +50,16 @@ trigger: always_on
|
||||
* Routes: `kebab-case` (小寫橫線分隔)
|
||||
* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||
|
||||
## 6. AI 協作規則 (給 Antigravity AI)
|
||||
## 6. 嚴格模組化通訊規範 (Strict Modular Communication)
|
||||
為了確保系統的可維護性與獨立性,所有模組必須遵守以下「實體解耦」規範:
|
||||
|
||||
* **禁止跨模組 Eloquent 關聯**:禁止在 Model 中定義指向其他模組的 `belongsTo`, `hasMany` 等關聯。
|
||||
* **介面化通訊 (Contracts)**:模組間的資料交換與功能調用必須透過 `app/Modules/{ModuleName}/Contracts/` 下定義的介面進行。
|
||||
* **禁止跨模組 Model 引用**:Controller 與 Service 禁止 `use` 其他模組的 Model (除非是該模組自身的 Contracts)。
|
||||
* **手動資料水和 (Manual Hydration)**:若頁面需要顯示跨模組資料(例:訂單顯示使用者名稱),Controller 應透過 Service 獲取基本資料,再手動組合成前端所需的 JSON/Props 結構。
|
||||
* **資料一致性**:跨模組的資料操作應由各模組的 Service 處理其內部的 transaction 完整性。
|
||||
|
||||
## 7. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
@@ -58,11 +67,27 @@ trigger: always_on
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 7. 運行機制 (Docker / Sail)
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||
* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。
|
||||
* **指令執行**:
|
||||
* **Seeders**: 必須執行 `./vendor/bin/sail php artisan tenants:run db:seed` 以確保所有租戶均獲得更新。
|
||||
* **Tinker**: 檢查租戶資料時應使用 `./vendor/bin/sail php artisan tenants:run tinker`。
|
||||
* **Migrations**: 租戶相關的 Schema 異動應放在 `database/migrations/tenant/` 並執行 `./vendor/bin/sail artisan tenants:migrate`。
|
||||
|
||||
## 9. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
||||
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
||||
* **執行 Composer**: `./vendor/bin/sail composer install`
|
||||
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||
|
||||
## 10. 日期處理 (Date Handling)
|
||||
|
||||
- 前端顯示日期時預設使用 `resources/js/lib/date.ts` 提供的 `formatDate` 工具。
|
||||
- 避免直接顯示原始 ISO 字串(如 `...Z` 結尾的格式)。
|
||||
- **智慧格式切換**:`formatDate` 會自動判斷原始資料,若時間部分為 `00:00:00` 則僅顯示 `YYYY-MM-DD`,否則顯示 `YYYY-MM-DD HH:mm:ss`。
|
||||
@@ -1,158 +1,111 @@
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
|
||||
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範
|
||||
# 操作紀錄實作規範 (Activity Logging Skill)
|
||||
|
||||
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
|
||||
本文件定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
|
||||
|
||||
## 1. 後端實作標準 (Backend)
|
||||
---
|
||||
|
||||
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
|
||||
## 1. 後端實作核心 (Backend)
|
||||
|
||||
### 1.1 啟用 Activity Log
|
||||
|
||||
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
|
||||
|
||||
```php
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
|
||||
->dontSubmitEmptyLogs(); // 若無變動則不記錄
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 手動記錄 (Manual Logging)
|
||||
|
||||
若需在 Controller 手動記錄(例如需客製化邏輯),**必須**自行實作變動過濾,不可直接儲存所有屬性。
|
||||
|
||||
**錯誤範例 (Do NOT do this):**
|
||||
```php
|
||||
// ❌ 錯誤:這會導致每次更新都記錄所有欄位,即使它們沒變
|
||||
activity()
|
||||
->withProperties(['attributes' => $newAttributes, 'old' => $oldAttributes])
|
||||
->log('updated');
|
||||
```
|
||||
|
||||
**正確範例 (Do this):**
|
||||
```php
|
||||
// ✅ 正確:自行比對差異,只存變動值
|
||||
$changedAttributes = [];
|
||||
$changedOldAttributes = [];
|
||||
|
||||
foreach ($newAttributes as $key => $value) {
|
||||
if ($value != ($oldAttributes[$key] ?? null)) {
|
||||
$changedAttributes[$key] = $value;
|
||||
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($changedAttributes)) {
|
||||
activity()
|
||||
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
|
||||
->log('updated');
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 快照策略 (Snapshot Strategy)
|
||||
|
||||
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊(如名稱、代號、類別名稱)。
|
||||
|
||||
**主要方式:使用 `tapActivity` (推薦)**
|
||||
### 1.1 全域 ID 轉名稱邏輯 (Global ID Resolution)
|
||||
為了讓管理者能直覺看懂日誌,所有的 ID(如 `warehouse_id`, `created_by`)在記錄時都應自動解析為名稱。此邏輯應統一在 Model 的 `tapActivity` 中實作。
|
||||
|
||||
#### 關鍵實作參考:
|
||||
```php
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
// 🚩 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
// 1. Snapshot 快照:用於主描述的上下文(例如:單號、名稱)
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
|
||||
$snapshot['category_name'] = $this->category ? $this->category->name : null;
|
||||
$snapshot['po_number'] = $this->code; // 儲存單號
|
||||
|
||||
// 保存自身名稱 (Context)
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$snapshot['doc_no'] = $this->doc_no;
|
||||
$snapshot['warehouse_name'] = $this->warehouse?->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 2. 名稱解析:自動將 attributes 與 old 中的 ID 換成人名/物名
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 使用者 ID 轉換
|
||||
foreach (['created_by', 'updated_by', 'completed_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
|
||||
}
|
||||
}
|
||||
// 倉庫 ID 轉換
|
||||
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($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 顯示名稱映射 (UI Mapping)
|
||||
### 1.2 複雜操作的日誌合併 (Log Consolidation)
|
||||
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
|
||||
|
||||
### 2.1 對象名稱映射 (Mapping)
|
||||
|
||||
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
|
||||
|
||||
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
|
||||
* **策略**:在 Service 層手動發送主日誌,並使用 `saveQuietly()` 更新單據屬性以抑止 Trait 的自動日誌。
|
||||
* **格式**:主日誌應包含 `items_diff` (品項差異) 與 `attributes/old` (單據狀態變更)。
|
||||
|
||||
```php
|
||||
protected function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||
];
|
||||
}
|
||||
// Service 中的實作方式
|
||||
DB::transaction(function () use ($doc, $items) {
|
||||
// 1. 更新品項 (記錄變動細節)
|
||||
$updatedItems = $this->getUpdatedItems($doc, $items);
|
||||
|
||||
// 2. 靜默更新單據狀態 (避免 Trait 產生冗餘日誌)
|
||||
$doc->status = 'completed';
|
||||
$doc->saveQuietly();
|
||||
|
||||
// 3. 手動觸發單一合併日誌
|
||||
activity()
|
||||
->performedOn($doc)
|
||||
->withProperties([
|
||||
'items_diff' => ['updated' => $updatedItems],
|
||||
'attributes' => ['status' => 'completed'],
|
||||
'old' => ['status' => 'counting']
|
||||
])
|
||||
->log('updated');
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 欄位名稱中文化 (Field Translation)
|
||||
---
|
||||
|
||||
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
|
||||
## 2. 前端介面規範 (Frontend)
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
### 2.1 標籤命名規範 (Field Labels)
|
||||
前端顯示應完全移除「ID」字眼,提供最友善的閱讀體驗。
|
||||
|
||||
**檔案位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
```typescript
|
||||
const fieldLabels: Record<string, string> = {
|
||||
// ... 既有欄位
|
||||
'transaction_date': '費用日期',
|
||||
'category': '費用類別',
|
||||
'amount': '金額',
|
||||
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
|
||||
created_by: '建立者', // ❌ 禁用「建立者 ID」
|
||||
completed_by: '完成者',
|
||||
status: '狀態',
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 前端顯示邏輯 (Frontend)
|
||||
### 2.2 特殊結構顯示
|
||||
* **品項異動**:前端應能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」的方式呈現表格(已在 `ActivityDetailDialog` 實作)。
|
||||
|
||||
### 3.1 列表描述生成 (Description Generation)
|
||||
---
|
||||
|
||||
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。
|
||||
## 3. 開發檢核清單 (Checklist)
|
||||
|
||||
若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
|
||||
|
||||
```typescript
|
||||
const nameParams = [
|
||||
'po_number', 'name', 'code',
|
||||
'category_name',
|
||||
'category' // ✅ 確保加入此欄位,前端才能抓到 $snapshot['category']
|
||||
];
|
||||
```
|
||||
|
||||
### 3.2 詳情過濾邏輯
|
||||
|
||||
前端 `ActivityDetailDialog` 已內建智慧過濾邏輯:
|
||||
- **Created**: 顯示初始化欄位。
|
||||
- **Updated**: **僅顯示有變動的欄位** (由 `isChanged` 判斷)。
|
||||
- **Deleted**: 顯示刪除前的完整資料。
|
||||
|
||||
開發者僅需確保傳入的 `attributes` 與 `old` 資料結構正確,過濾邏輯會自動運作。
|
||||
|
||||
## 檢核清單
|
||||
|
||||
- [ ] **Backend**: Model 是否已設定 `logOnlyDirty` 或手動實作過濾?
|
||||
- [ ] **Backend**: 是否已透過 `tapActivity` 或手動方式記錄 Snapshot(關鍵名稱)?
|
||||
- [ ] **Backend**: 是否已在 `ActivityLogController` 加入 Model 中文名稱映射?
|
||||
- [ ] **Frontend**: 是否已在 `ActivityDetailDialog` 加入欄位中文翻譯?
|
||||
- [ ] **Frontend**: 若使用特殊識別欄位,是否已加入 `LogTable` 的 `nameParams`?
|
||||
- [ ] **Model**: `tapActivity` 是否已處理 Collection 快照?
|
||||
- [ ] **Model**: 是否已實作全域 ID 至名稱的自動解析?
|
||||
- [ ] **Service**: 是否使用 `saveQuietly()` 避免產生重複的「單據已更新」日誌?
|
||||
- [ ] **UI**: `fieldLabels` 是否已移除所有「ID」字樣?
|
||||
- [ ] **UI**: 若有品項異動,是否已正確格式化傳入 `items_diff`?
|
||||
|
||||
@@ -123,8 +123,8 @@ tooltip
|
||||
// ✅ 成功操作
|
||||
<Button className="button-filled-success">確認</Button>
|
||||
|
||||
// ✅ 資訊操作
|
||||
<Button className="button-filled-info">查看詳情</Button>
|
||||
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
|
||||
<Button className="button-filled-info">系統資訊</Button>
|
||||
|
||||
// ✅ 警告操作
|
||||
<Button className="button-filled-warning">警告</Button>
|
||||
@@ -177,6 +177,23 @@ tooltip
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列檢視按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.view">
|
||||
<Link href={route('resource.show', item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="檢視"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列編輯按鈕
|
||||
|
||||
```tsx
|
||||
@@ -230,6 +247,89 @@ tooltip
|
||||
</Can>
|
||||
```
|
||||
|
||||
### 3.4 返回按鈕規範
|
||||
|
||||
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
|
||||
|
||||
**樣式規格**:
|
||||
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
|
||||
- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"`
|
||||
- **圖標**:`<ArrowLeft className="h-4 w-4" />`
|
||||
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
|
||||
|
||||
```tsx
|
||||
<div className="mb-6">
|
||||
<Link href={route('resource.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3.5 頁面佈局規範(新增/編輯頁面)
|
||||
|
||||
### 標準結構
|
||||
|
||||
新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構:
|
||||
|
||||
```tsx
|
||||
<AuthenticatedLayout breadcrumbs={...}>
|
||||
<Head title="..." />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
{/* 返回按鈕 */}
|
||||
<Link href={route('resource.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 頁面標題區塊 */}
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Icon className="h-6 w-6 text-primary-main" />
|
||||
頁面標題
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
頁面說明文字
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表單或內容區塊 */}
|
||||
<FormComponent ... />
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
```
|
||||
|
||||
### 關鍵規範
|
||||
|
||||
1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致
|
||||
2. **Header 包裹**:使用 `<div className="mb-6">` 包裹返回按鈕與標題區塊
|
||||
3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔
|
||||
4. **標題區塊**:使用 `<div className="mb-4">` 包裹 h1 和 p 標籤
|
||||
5. **標題樣式**:`text-2xl font-bold text-grey-0 flex items-center gap-2`
|
||||
6. **說明文字**:`text-gray-500 mt-1`
|
||||
|
||||
### 範例頁面
|
||||
|
||||
- ✅ `/resources/js/Pages/PurchaseOrder/Create.tsx`(建立採購單)
|
||||
- ✅ `/resources/js/Pages/Product/Create.tsx`(新增商品)
|
||||
- ✅ `/resources/js/Pages/Product/Edit.tsx`(編輯商品)
|
||||
|
||||
---
|
||||
|
||||
## 4. 圖標規範
|
||||
@@ -426,23 +526,27 @@ const handleSort = (field: string) => {
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
// 在表格下方
|
||||
// 在表格下方(底部工具列)
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[80px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[90px] h-8" // ✅ 統一使用 90px 寬度,避免「100」顯示不全
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
{/* 總筆數顯示:統一放在每頁顯示右側,使用 text-gray-500 */}
|
||||
<span className="text-sm text-gray-500">共 {data.total} 筆紀錄</span>
|
||||
</div>
|
||||
<Pagination links={data.links} />
|
||||
</div>
|
||||
@@ -465,6 +569,7 @@ const handlePerPageChange = (value: string) => {
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 7. Badge 與狀態顯示
|
||||
|
||||
### 7.1 基本 Badge
|
||||
@@ -510,6 +615,48 @@ import { Badge } from "@/Components/ui/badge";
|
||||
</div>
|
||||
```
|
||||
|
||||
### 7.3 統一狀態標籤 (StatusBadge)
|
||||
|
||||
系統提供統一的 `StatusBadge` 元件來顯示各種業務狀態,確保顏色與樣式的一致性。
|
||||
|
||||
**引入方式**:
|
||||
|
||||
```tsx
|
||||
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||
```
|
||||
|
||||
**支援的變體 (Variant)**:
|
||||
|
||||
| Variant | 顏色 | 適用情境 |
|
||||
|---|---|---|
|
||||
| `neutral` | 灰色 | 草稿、取消、關閉、缺貨 |
|
||||
| `info` | 藍色 | 處理中、啟用中 |
|
||||
| `warning` | 黃色 | 待審核、庫存預警、週轉慢 |
|
||||
| `success` | 綠色 | 已完成、已核准、正常 |
|
||||
| `destructive` | 紅色 | 作廢、駁回、滯銷、異常 |
|
||||
|
||||
**實作模式**:
|
||||
|
||||
建議定義一個 `getStatusVariant` 函式將業務狀態對應到 UI 變體,保持程式碼整潔。
|
||||
|
||||
```tsx
|
||||
// 1. 定義狀態映射函式
|
||||
const getStatusVariant = (status: string): StatusVariant => {
|
||||
switch (status) {
|
||||
case 'normal': return 'success'; // 正常 -> 綠色
|
||||
case 'slow': return 'warning'; // 週轉慢 -> 黃色
|
||||
case 'dead': return 'destructive'; // 滯銷 -> 紅色
|
||||
case 'out_of_stock': return 'neutral';// 缺貨 -> 灰色
|
||||
default: return 'neutral';
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 在表格中使用
|
||||
<StatusBadge variant={getStatusVariant(item.status)}>
|
||||
{item.status_label}
|
||||
</StatusBadge>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 頁面佈局規範
|
||||
@@ -751,7 +898,42 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
```tsx
|
||||
import { Calendar } from "lucide-react";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
## 11.7 金額與數字輸入規範
|
||||
|
||||
所有涉及金額(單價、成本、總價)的輸入框,應遵循以下規範以確保操作體驗一致:
|
||||
|
||||
1. **HTML 屬性**:
|
||||
* `type="number"`
|
||||
* `min="0"` (除非業務邏輯允許負數)
|
||||
* `step="any"` (設置為 `any` 可允許任意小數,且瀏覽器預設按上下鍵時會增減 **1** 並保留小數部分,例如 37.2 -> 38.2)
|
||||
* **步進值 (Step)**: 金額與數量輸入框均應設定 `step="any"`,以支援小數點輸入(除非業務邏輯強制整數)。
|
||||
* `placeholder="0"`
|
||||
2. **樣式類別**:
|
||||
* 預設靠左對齊 (不需要 `text-right`),亦可依版面需求調整。
|
||||
|
||||
### 9.2 對齊方式 (Alignment)
|
||||
|
||||
依據欄位所在的情境區分對齊方式:
|
||||
|
||||
- **明細列表/表格 (Details/Table)**:金額與數量欄位一律 **靠右對齊 (text-right)**。
|
||||
- 包含:採購單明細、庫存盤點表、調撥單明細等 Table 內的輸入框。
|
||||
- **一般表單/新增欄位 (Form/Input)**:金額與數量欄位一律 **靠左對齊 (text-left)**。
|
||||
- 包含:商品資料設定、新增表單中的獨立欄位。亦可依版面需求調整。
|
||||
3. **行為邏輯**:
|
||||
* 輸入時允許輸入小數點。
|
||||
* 鍵盤上下鍵調整時,瀏覽器會預設增減 1 (搭配 `step="any"`)。
|
||||
|
||||
```tsx
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
/>
|
||||
```
|
||||
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
@@ -947,3 +1129,132 @@ import { Pencil } from 'lucide-react';
|
||||
5. ✅ **安全性**:統一的權限控制確保資料安全
|
||||
|
||||
當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範!
|
||||
|
||||
---
|
||||
|
||||
## 15. 批次匯入彈窗規範 (Batch Import Dialog)
|
||||
|
||||
為了確保系統中所有批次匯入功能(如:商品、庫存、客戶)的體驗一致,必須遵循以下 UI 結構與樣式。
|
||||
|
||||
### 15.1 標題結構
|
||||
|
||||
- **樣式**:保持簡潔,僅使用文字標題,不帶額外圖示。
|
||||
- **文字**:統一為「匯入XXXX資料」。
|
||||
|
||||
```tsx
|
||||
<DialogHeader>
|
||||
<DialogTitle>匯入商品資料</DialogTitle>
|
||||
<DialogDescription>
|
||||
請先下載範本,填寫完畢後上傳檔案進行批次處理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
```
|
||||
|
||||
### 15.2 分步引導區塊 (Step-by-Step Guide)
|
||||
|
||||
匯入流程必須分為三個清晰的步驟區塊:
|
||||
|
||||
#### 步驟 1:取得匯入範本
|
||||
- **容器樣式**:`bg-gray-50 rounded-lg border border-gray-100 p-4 space-y-2`
|
||||
- **標題圖示**:`<FileSpreadsheet className="w-4 h-4 text-green-600" />`
|
||||
- **下載按鈕**:`variant="outline" size="sm" className="w-full sm:w-auto button-outlined-primary"`,並明確標註 `.xlsx`。
|
||||
|
||||
#### 步驟 2:設定資訊 (選甜)
|
||||
- **容器樣式**:`space-y-2`
|
||||
- **標題圖示**:`<Info className="w-4 h-4 text-primary-main" />`
|
||||
- **欄位樣式**:使用標準 `Input`,標籤文字使用 `text-sm text-gray-700`。
|
||||
- **預設值**:若有備註欄位,應提供合適的預設值(例如:「Excel 匯入」)。
|
||||
|
||||
#### 步驟 3:上傳填寫後的檔案
|
||||
- **容器樣式**:`space-y-2`
|
||||
- **標題圖示**:`<FileUp className="w-4 h-4 text-blue-600" />`
|
||||
- **Input 樣式**:`type="file"`,並開啟 `cursor-pointer`。
|
||||
|
||||
### 15.3 規則說明面板 (Accordion Rules)
|
||||
|
||||
詳細的填寫說明必須收納於 `Accordion` 中,避免干擾主流程:
|
||||
|
||||
- **樣式**:標準灰色邊框,不使用特殊背景色 (如琥珀色)。
|
||||
- **容器**:`className="w-full border rounded-lg px-2"`
|
||||
- **觸發文字**:`text-sm text-gray-500`
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible className="w-full border rounded-lg px-2">
|
||||
<AccordionItem value="rules" className="border-b-0">
|
||||
<AccordionTrigger className="text-sm text-gray-500 hover:no-underline py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
匯入規則與提示
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
|
||||
<ul className="list-disc space-y-1">
|
||||
<li>使用加粗文字標註關鍵欄位:<span className="font-medium text-gray-700">關鍵字</span></li>
|
||||
<li>說明文字簡潔明瞭。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
### 15.4 底部操作 (Footer)
|
||||
|
||||
- **取消按鈕**:`variant="outline"`,且為 `button-outlined-primary`。
|
||||
- **提交按鈕**:`button-filled-primary`,且在處理中時顯示 `Loader2`。
|
||||
|
||||
---
|
||||
|
||||
## 16. 詳情頁面項目清單規範 (Detail Page Item List Standards)
|
||||
|
||||
為了確保詳情頁面(如:採購單詳情、進貨單詳情、銷售匯入詳情)的資訊層級清晰且視覺統一,所有項目清單必須遵循以下規範。
|
||||
|
||||
### 16.1 容器結構 (Container Structure)
|
||||
|
||||
項目清單應封裝在一個帶有內距的卡片容器中,而不是讓表格直接緊貼外層卡片邊緣。
|
||||
|
||||
1. **外層卡片**:`bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden`
|
||||
2. **標題區塊**:`p-6 border-b border-gray-100 bg-gray-50/30`
|
||||
3. **內容內距**:標題下方的內容區塊應加上 `p-6`。
|
||||
4. **表格包裹層**:表格應再包裹一層 `border rounded-lg overflow-hidden`,以確保表格內部的邊角與隔線視覺完整。
|
||||
|
||||
```tsx
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* 標題 */}
|
||||
<div className="p-6 border-b border-gray-100 bg-gray-50/30">
|
||||
<h2 className="text-lg font-bold text-gray-900">項目清單標題</h2>
|
||||
</div>
|
||||
|
||||
{/* 內容區塊 */}
|
||||
<div className="p-6">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
{/* 標頭欄位 */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* 表格內容 */}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 若有分頁,直接放在 p-6 容器內,並加 mt-6 分隔 */}
|
||||
<div className="mt-6">
|
||||
<Pagination ... />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 16.2 表格樣式細節 (Table Styling)
|
||||
|
||||
1. **標頭背景**:`TableHeader` 的第一個 `TableRow` 應使用 `bg-gray-50 hover:bg-gray-50` 強化視覺區隔。
|
||||
2. **文字顏色**:主體文字使用 `text-gray-900`(標題/重要數據)或 `text-gray-500`(輔助/序號)。
|
||||
3. **數據對齊**:
|
||||
* **數量/序號**:文字置中 (`text-center`) 或依據數據類型對齊。
|
||||
* **金額**:金額欄位必須使用 `text-right` 並視情況加粗 (`font-bold`) 或加上 `text-primary-main` 顏色。
|
||||
4. **表格隔線**:確保表格具有清晰但不過於突出的水平隔線,提升長列表的可讀性。
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=v1.0.0
|
||||
|
||||
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
|
||||
CENTRAL_DOMAINS=localhost,127.0.0.1
|
||||
@@ -43,7 +44,7 @@ BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=redis
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal file
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範
|
||||
|
||||
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
|
||||
|
||||
## 1. 後端實作標準 (Backend)
|
||||
|
||||
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
|
||||
|
||||
### 1.1 啟用 Activity Log
|
||||
|
||||
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
|
||||
|
||||
```php
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
|
||||
->dontSubmitEmptyLogs(); // 若無變動則不記錄
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 手動記錄 (Manual Logging)
|
||||
|
||||
若需在 Controller 手動記錄(例如需客製化邏輯),**必須**自行實作變動過濾,不可直接儲存所有屬性。
|
||||
|
||||
**錯誤範例 (Do NOT do this):**
|
||||
```php
|
||||
// ❌ 錯誤:這會導致每次更新都記錄所有欄位,即使它們沒變
|
||||
activity()
|
||||
->withProperties(['attributes' => $newAttributes, 'old' => $oldAttributes])
|
||||
->log('updated');
|
||||
```
|
||||
|
||||
**正確範例 (Do this):**
|
||||
```php
|
||||
// ✅ 正確:自行比對差異,只存變動值
|
||||
$changedAttributes = [];
|
||||
$changedOldAttributes = [];
|
||||
|
||||
foreach ($newAttributes as $key => $value) {
|
||||
if ($value != ($oldAttributes[$key] ?? null)) {
|
||||
$changedAttributes[$key] = $value;
|
||||
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($changedAttributes)) {
|
||||
activity()
|
||||
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
|
||||
->log('updated');
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 快照策略 (Snapshot Strategy)
|
||||
|
||||
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊(如名稱、代號、類別名稱)。
|
||||
|
||||
**主要方式:使用 `tapActivity` (推薦)**
|
||||
|
||||
```php
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
|
||||
$snapshot['category_name'] = $this->category ? $this->category->name : null;
|
||||
$snapshot['po_number'] = $this->code; // 儲存單號
|
||||
|
||||
// 保存自身名稱 (Context)
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 顯示名稱映射 (UI Mapping)
|
||||
|
||||
### 2.1 對象名稱映射 (Mapping)
|
||||
|
||||
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
|
||||
|
||||
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
|
||||
|
||||
```php
|
||||
protected function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 欄位名稱中文化 (Field Translation)
|
||||
|
||||
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
|
||||
```typescript
|
||||
const fieldLabels: Record<string, string> = {
|
||||
// ... 既有欄位
|
||||
'transaction_date': '費用日期',
|
||||
'category': '費用類別',
|
||||
'amount': '金額',
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 前端顯示邏輯 (Frontend)
|
||||
|
||||
### 3.1 列表描述生成 (Description Generation)
|
||||
|
||||
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。
|
||||
|
||||
若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
|
||||
|
||||
```typescript
|
||||
const nameParams = [
|
||||
'po_number', 'name', 'code',
|
||||
'category_name',
|
||||
'category' // ✅ 確保加入此欄位,前端才能抓到 $snapshot['category']
|
||||
];
|
||||
```
|
||||
|
||||
### 3.2 詳情過濾邏輯
|
||||
|
||||
前端 `ActivityDetailDialog` 已內建智慧過濾邏輯:
|
||||
- **Created**: 顯示初始化欄位。
|
||||
- **Updated**: **僅顯示有變動的欄位** (由 `isChanged` 判斷)。
|
||||
- **Deleted**: 顯示刪除前的完整資料。
|
||||
|
||||
開發者僅需確保傳入的 `attributes` 與 `old` 資料結構正確,過濾邏輯會自動運作。
|
||||
|
||||
## 檢核清單
|
||||
|
||||
- [ ] **Backend**: Model 是否已設定 `logOnlyDirty` 或手動實作過濾?
|
||||
- [ ] **Backend**: 是否已透過 `tapActivity` 或手動方式記錄 Snapshot(關鍵名稱)?
|
||||
- [ ] **Backend**: 是否已在 `ActivityLogController` 加入 Model 中文名稱映射?
|
||||
- [ ] **Frontend**: 是否已在 `ActivityDetailDialog` 加入欄位中文翻譯?
|
||||
- [ ] **Frontend**: 若使用特殊識別欄位,是否已加入 `LogTable` 的 `nameParams`?
|
||||
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal file
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: 權限管理與實作規範
|
||||
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||
---
|
||||
|
||||
# 權限管理與實作規範
|
||||
|
||||
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
|
||||
|
||||
## 1. 定義權限 (Backend)
|
||||
|
||||
所有權限皆定義於 `database/seeders/PermissionSeeder.php`。
|
||||
|
||||
### 步驟:
|
||||
|
||||
1. 開啟 `database/seeders/PermissionSeeder.php`。
|
||||
2. 在 `$permissions` 陣列中新增功能對應的權限字串。
|
||||
* **命名慣例**:`{resource}.{action}` (例如:`system.view_logs`, `products.create`)
|
||||
* 常用動作:`view`, `create`, `edit`, `delete`, `publish`, `export`
|
||||
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
|
||||
* `super-admin`:通常擁有所有權限(程式碼中 `Permission::all()` 自動涵蓋,無需手動新增)。
|
||||
* `admin`:通常擁有大部分權限。
|
||||
* 其他角色 (`warehouse-manager`, `purchaser`, `viewer`):依業務邏輯分配。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 1. 新增權限字串
|
||||
$permissions = [
|
||||
// ... 現有權限
|
||||
'system.view_logs', // 新增:檢視系統日誌
|
||||
];
|
||||
|
||||
// ...
|
||||
|
||||
// 2. 分配給角色
|
||||
$admin->givePermissionTo([
|
||||
// ... 現有權限
|
||||
'system.view_logs',
|
||||
]);
|
||||
```
|
||||
|
||||
## 2. 套用資料庫變更
|
||||
|
||||
修改 Seeder 後,必須重新執行 Seeder 以將權限寫入資料庫。
|
||||
|
||||
```bash
|
||||
# 對於所有租戶執行 Seeder (開發環境)
|
||||
php artisan tenants:seed --class=PermissionSeeder
|
||||
```
|
||||
|
||||
## 3. 路由保護 (Backend Middleware)
|
||||
|
||||
在 `routes/web.php` 中,使用 `permission:{name}` middleware 保護路由。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 單一權限保護
|
||||
Route::get('/logs', [LogController::class, 'index'])
|
||||
->middleware('permission:system.view_logs')
|
||||
->name('logs.index');
|
||||
|
||||
// 路由群組保護
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
// ...
|
||||
});
|
||||
|
||||
// 多重權限 (OR 邏輯:有其一即可)
|
||||
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## 4. 前端權限判斷 (React Component)
|
||||
|
||||
使用自訂 Hook `usePermission` 來控制 UI 元素的顯示(例如:隱藏沒有權限的按鈕)。
|
||||
|
||||
### 引入 Hook:
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
```
|
||||
|
||||
### 使用方式:
|
||||
|
||||
```tsx
|
||||
export default function ProductIndex() {
|
||||
const { can } = usePermission();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>商品列表</h1>
|
||||
|
||||
{/* 只有擁有 create 權限才顯示按鈕 */}
|
||||
{can('products.create') && (
|
||||
<Button>新增商品</Button>
|
||||
)}
|
||||
|
||||
{/* 組合判斷 */}
|
||||
{can('products.edit') && <EditButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 權限 Hook 介面說明:
|
||||
|
||||
- `can(permission: string)`: 檢查當前使用者是否擁有指定權限。
|
||||
- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
|
||||
- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
|
||||
|
||||
## 5. 配置權限群組名稱 (Backend UI Config)
|
||||
|
||||
為了讓新權限在「角色與權限」管理介面中顯示正確的中文分組標題,需修改 Controller 設定。
|
||||
|
||||
### 步驟:
|
||||
|
||||
1. 開啟 `app/Http/Controllers/Admin/RoleController.php`。
|
||||
2. 找到 `getGroupedPermissions` 方法。
|
||||
3. 在 `$groupDefinitions` 陣列中,新增 `{resource}` 對應的中文名稱。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
$groupDefinitions = [
|
||||
'products' => '商品資料管理',
|
||||
// ...
|
||||
'utility_fees' => '公共事業費管理', // 新增此行
|
||||
];
|
||||
```
|
||||
|
||||
## 檢核清單
|
||||
|
||||
- [ ] `PermissionSeeder.php` 已新增權限字串。
|
||||
- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
|
||||
- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
|
||||
- [ ] `RoleController.php` 已新增權限群組的中文名稱映射。
|
||||
- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
|
||||
- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。
|
||||
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal file
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal file
@@ -0,0 +1,990 @@
|
||||
---
|
||||
name: 客戶端後台 UI 統一規範
|
||||
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
|
||||
---
|
||||
|
||||
# 客戶端後台 UI 統一規範
|
||||
|
||||
## 概述
|
||||
|
||||
本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。
|
||||
|
||||
> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
|
||||
|
||||
## 核心原則
|
||||
|
||||
1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件
|
||||
2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別
|
||||
3. **統一的圖標系統**:全面使用 `lucide-react` 圖標
|
||||
4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構
|
||||
5. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
|
||||
|
||||
---
|
||||
|
||||
## 1. 專案結構
|
||||
|
||||
### 1.1 關鍵目錄
|
||||
|
||||
```
|
||||
resources/
|
||||
├── css/
|
||||
│ └── app.css # 全域樣式與設計 Token
|
||||
├── js/
|
||||
│ ├── Components/
|
||||
│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui)
|
||||
│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等)
|
||||
│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll)
|
||||
│ ├── Layouts/
|
||||
│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用
|
||||
│ │ └── LandlordLayout.tsx # 中央管理後台佈局
|
||||
│ └── Pages/ # 頁面元件
|
||||
```
|
||||
|
||||
### 1.2 可用 UI 元件清單
|
||||
|
||||
```
|
||||
accordion, alert, alert-dialog, avatar, badge, breadcrumb, button,
|
||||
calendar, card, carousel, chart, checkbox, collapsible, command,
|
||||
context-menu, dialog, drawer, dropdown-menu, form, hover-card,
|
||||
input, input-otp, label, menubar, navigation-menu, pagination,
|
||||
popover, progress, radio-group, resizable, scroll-area,
|
||||
searchable-select, select, separator, sheet, sidebar, skeleton,
|
||||
slider, sonner, switch, table, tabs, textarea, toggle, toggle-group,
|
||||
tooltip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 色彩系統
|
||||
|
||||
### 2.1 主題色 (Primary) - **動態租戶品牌色**
|
||||
|
||||
> **注意**:主題色會根據租戶設定(Branding)動態改變,**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。
|
||||
> 請務必使用 Tailwind Utility Class 或 CSS 變數。
|
||||
|
||||
| Tailwind Class | CSS Variable | 說明 |
|
||||
|----------------|--------------|------|
|
||||
| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 |
|
||||
| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 |
|
||||
| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 |
|
||||
| `*-primary-lightest` | `--primary-lightest` | **最淺色**:系統自動計算,用於背景底色、Active 狀態 |
|
||||
|
||||
**運作機制**:
|
||||
`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。
|
||||
|
||||
```tsx
|
||||
// ✅ 正確:使用 Tailwind Class
|
||||
<div className="text-primary-main">...</div>
|
||||
|
||||
// ✅ 正確:使用 CSS 變數 (自定義樣式時)
|
||||
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
|
||||
|
||||
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
|
||||
<div className="text-[#01ab83]">...</div>
|
||||
```
|
||||
|
||||
### 2.2 灰階 (Grey Scale)
|
||||
|
||||
```css
|
||||
--grey-0: #1a1a1a; /* 深黑 - 標題文字 */
|
||||
--grey-1: #4a4a4a; /* 深灰 - 主要內文 */
|
||||
--grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */
|
||||
--grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */
|
||||
--grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */
|
||||
--grey-5: #fff; /* 白色 - 背景、按鈕文字 */
|
||||
```
|
||||
|
||||
### 2.3 狀態色 (State Colors)
|
||||
|
||||
```css
|
||||
--other-success: #01ab83; /* 成功 - 同主題色 */
|
||||
--other-error: #dc2626; /* 錯誤 - 刪除、警示 */
|
||||
--other-warning: #f59e0b; /* 警告 - 提醒、注意 */
|
||||
--other-info: #3b82f6; /* 資訊 - 說明、提示 */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 按鈕規範
|
||||
|
||||
### 3.1 按鈕樣式類別
|
||||
|
||||
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別:
|
||||
|
||||
#### Filled 按鈕(實心按鈕)— 用於主要操作
|
||||
|
||||
```tsx
|
||||
// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增項目
|
||||
</Button>
|
||||
|
||||
// ✅ 成功操作
|
||||
<Button className="button-filled-success">確認</Button>
|
||||
|
||||
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
|
||||
<Button className="button-filled-info">系統資訊</Button>
|
||||
|
||||
// ✅ 警告操作
|
||||
<Button className="button-filled-warning">警告</Button>
|
||||
|
||||
// ✅ 錯誤/刪除操作(AlertDialog 內確認按鈕)
|
||||
<Button className="button-filled-error">刪除</Button>
|
||||
```
|
||||
|
||||
#### Outlined 按鈕(邊框按鈕)— 用於次要操作
|
||||
|
||||
```tsx
|
||||
// ✅ 編輯按鈕(表格操作列)
|
||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
// ✅ 刪除按鈕(表格操作列)
|
||||
<Button variant="outline" size="sm" className="button-outlined-error">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### Text 按鈕(文字按鈕)
|
||||
|
||||
```tsx
|
||||
<Button className="button-text-primary">查看更多</Button>
|
||||
```
|
||||
|
||||
### 3.2 按鈕大小
|
||||
|
||||
| Size | 高度 | 使用情境 |
|
||||
|------|------|----------|
|
||||
| `size="sm"` | h-8 | 表格操作列、緊湊佈局 |
|
||||
| `size="default"` | h-9 | 一般操作、表單提交 |
|
||||
| `size="lg"` | h-10 | 主要 CTA、頁面主操作 |
|
||||
| `size="icon"` | 9×9 | 純圖標按鈕 |
|
||||
|
||||
### 3.3 常見操作按鈕模式
|
||||
|
||||
#### 頁面頂部新增按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.create">
|
||||
<Link href={route('resource.create')}>
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增XXX
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列檢視按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.view">
|
||||
<Link href={route('resource.show', item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="檢視"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列編輯按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.edit">
|
||||
<Link href={route('resource.edit', item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="編輯"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列刪除按鈕(帶確認對話框)
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.delete">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除「{item.name}」嗎?此操作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
```
|
||||
|
||||
### 3.4 返回按鈕規範
|
||||
|
||||
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
|
||||
|
||||
**樣式規格**:
|
||||
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
|
||||
- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"`
|
||||
- **圖標**:`<ArrowLeft className="h-4 w-4" />`
|
||||
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
|
||||
|
||||
```tsx
|
||||
<div className="mb-6">
|
||||
<Link href={route('resource.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 圖標規範
|
||||
|
||||
### 4.1 統一使用 lucide-react
|
||||
|
||||
**統一使用 `lucide-react`**,禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。
|
||||
|
||||
### 4.2 圖標尺寸標準
|
||||
|
||||
| 尺寸 | 類別 | 使用情境 |
|
||||
|------|------|----------|
|
||||
| 小型 | `h-3 w-3` | Badge 內、小文字旁 |
|
||||
| 標準 | `h-4 w-4` | 按鈕內、表格操作 |
|
||||
| 標題 | `h-5 w-5` | 側邊欄選單 |
|
||||
| 大型 | `h-6 w-6` | 頁面標題 |
|
||||
|
||||
### 4.3 常用操作圖標映射
|
||||
|
||||
| 操作 | 圖標組件 | 使用情境 |
|
||||
|------|----------|----------|
|
||||
| 新增 | `<Plus />` | 新增按鈕 |
|
||||
| 編輯 | `<Pencil />` | 編輯按鈕 |
|
||||
| 刪除 | `<Trash2 />` | 刪除按鈕 |
|
||||
| 查看 | `<Eye />` | 查看詳情 |
|
||||
| 搜尋 | `<Search />` | 搜尋欄位 |
|
||||
| 篩選 | `<Filter />` | 篩選功能 |
|
||||
| 下載 | `<Download />` | 下載/匯出 |
|
||||
| 上傳 | `<Upload />` | 上傳/匯入 |
|
||||
| 設定 | `<Settings />` | 設定功能 |
|
||||
| 複製 | `<Copy />` | 複製內容 |
|
||||
| 郵件 | `<Mail />` | Email 顯示 |
|
||||
| 使用者 | `<Users />`, `<User />` | 使用者管理 |
|
||||
| 權限 | `<Shield />` | 角色/權限 |
|
||||
| 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 |
|
||||
| 儀表板 | `<LayoutDashboard />` | 首頁/總覽 |
|
||||
| 商品 | `<Package />` | 商品管理 |
|
||||
| 倉庫 | `<Warehouse />` | 倉庫管理 |
|
||||
| 廠商 | `<Truck />`, `<Contact2 />` | 廠商管理 |
|
||||
| 採購 | `<ShoppingCart />` | 採購管理 |
|
||||
|
||||
### 4.4 圖標使用範例
|
||||
|
||||
```tsx
|
||||
import { Plus, Pencil, Trash2, Users } from 'lucide-react';
|
||||
|
||||
// 頁面標題
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||
使用者管理
|
||||
</h1>
|
||||
|
||||
// 按鈕內圖標(圖標在左,帶文字)
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增使用者
|
||||
</Button>
|
||||
|
||||
// 純圖標按鈕(表格操作列)
|
||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 表格規範
|
||||
|
||||
### 5.1 表格容器
|
||||
|
||||
```tsx
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
{/* 表格內容 */}
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.2 表格標題列
|
||||
|
||||
```tsx
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>名稱</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
```
|
||||
|
||||
**關鍵要點**:
|
||||
- 使用 `bg-gray-50` 背景色
|
||||
- 序號欄位固定寬度 `w-[50px]` 並置中
|
||||
- 操作欄位置中顯示
|
||||
|
||||
### 5.3 表格主體
|
||||
|
||||
```tsx
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
|
||||
無符合條件的資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{startIndex + index}
|
||||
</TableCell>
|
||||
{/* 其他欄位 */}
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{/* 操作按鈕 */}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
```
|
||||
|
||||
**關鍵要點**:
|
||||
- 空狀態訊息使用置中、灰色文字
|
||||
- 序號欄使用 `text-gray-500 font-medium text-center`
|
||||
- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕
|
||||
|
||||
### 5.4 欄位排序規範
|
||||
|
||||
當表格需要支援排序時,請遵循以下模式:
|
||||
|
||||
1. **圖標邏輯**:
|
||||
* 未排序:`ArrowUpDown` (class: `text-muted-foreground`)
|
||||
* 升冪 (asc):`ArrowUp` (class: `text-primary`)
|
||||
* 降冪 (desc):`ArrowDown` (class: `text-primary`)
|
||||
2. **結構**:在 `TableHead` 內使用 `button` 元素。
|
||||
3. **後端配合**:後端 Controller **必須** 處理 `sort_by` 與 `sort_order` 參數。
|
||||
|
||||
```tsx
|
||||
// 1. 定義 Helper Component (在元件內部)
|
||||
const SortIcon = ({ field }: { field: string }) => {
|
||||
if (filters.sort_by !== field) {
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
|
||||
}
|
||||
if (filters.sort_order === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
|
||||
}
|
||||
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
|
||||
};
|
||||
|
||||
// 2. 表格標題應用
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() => handleSort('created_at')}
|
||||
className="flex items-center hover:text-gray-900"
|
||||
>
|
||||
建立時間 <SortIcon field="created_at" />
|
||||
</button>
|
||||
</TableHead>
|
||||
|
||||
// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序)
|
||||
const handleSort = (field: string) => {
|
||||
let newSortBy: string | undefined = field;
|
||||
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||
|
||||
if (filters.sort_by === field) {
|
||||
if (filters.sort_order === 'asc') {
|
||||
newSortOrder = 'desc';
|
||||
} else {
|
||||
// desc -> reset (回到預設排序)
|
||||
newSortBy = undefined;
|
||||
newSortOrder = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
route(route().current()!),
|
||||
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 分頁規範
|
||||
|
||||
### 6.1 統一分頁元件
|
||||
|
||||
使用 `@/Components/shared/Pagination` 元件:
|
||||
|
||||
```tsx
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
// 在表格下方
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[80px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<Pagination links={data.links} />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6.2 每頁筆數狀態管理
|
||||
|
||||
```tsx
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route('resource.index'),
|
||||
{ per_page: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Badge 與狀態顯示
|
||||
|
||||
### 7.1 基本 Badge
|
||||
|
||||
```tsx
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
// Outline 樣式(最常用)
|
||||
<Badge variant="outline">{item.category?.name || '-'}</Badge>
|
||||
|
||||
// 預設樣式(主題色背景)
|
||||
<Badge variant="default">啟用中</Badge>
|
||||
|
||||
// 錯誤樣式
|
||||
<Badge variant="destructive">停用</Badge>
|
||||
```
|
||||
|
||||
### 7.2 角色顯示(特殊樣式)
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.roles.map(role => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-md border",
|
||||
role.name === 'super-admin'
|
||||
? "bg-purple-50 border-purple-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{role.name === 'super-admin' && <Shield className="h-3.5 w-3.5 text-purple-600" />}
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
role.name === 'super-admin' ? "text-purple-700" : "text-gray-900"
|
||||
)}>
|
||||
{role.display_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 頁面佈局規範
|
||||
|
||||
### 8.1 頁面結構
|
||||
|
||||
```tsx
|
||||
export default function ResourceIndex() {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '分類名稱', href: '#' },
|
||||
{ label: '頁面名稱', href: route('resource.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="頁面標題" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面頭部 */}
|
||||
{/* 主要內容 */}
|
||||
{/* 分頁元件 */}
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 標準頁面頭部
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<IconComponent className="h-6 w-6 text-[#01ab83]" />
|
||||
頁面標題
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
頁面說明文字
|
||||
</p>
|
||||
</div>
|
||||
<Can permission="resource.create">
|
||||
<Link href={route('resource.create')}>
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增項目
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 權限控制規範
|
||||
|
||||
### 9.1 使用 Can 元件
|
||||
|
||||
**所有**涉及權限的 UI 元素都必須使用 `<Can>` 元件包裹:
|
||||
|
||||
```tsx
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
|
||||
<Can permission="resource.create">
|
||||
{/* 新增按鈕 */}
|
||||
</Can>
|
||||
|
||||
<Can permission="resource.edit">
|
||||
{/* 編輯按鈕 */}
|
||||
</Can>
|
||||
|
||||
<Can permission="resource.delete">
|
||||
{/* 刪除按鈕 */}
|
||||
</Can>
|
||||
```
|
||||
|
||||
### 9.2 權限命名規範
|
||||
|
||||
遵循 `resource.action` 格式:
|
||||
|
||||
- `resource.view`:查看列表/詳情
|
||||
- `resource.create`:新增
|
||||
- `resource.edit`:編輯
|
||||
- `resource.delete`:刪除
|
||||
|
||||
### 9.3 多權限判斷
|
||||
|
||||
```tsx
|
||||
// 滿足任一權限即可
|
||||
<Can permission={['products.edit', 'products.delete']}>
|
||||
<div>管理操作</div>
|
||||
</Can>
|
||||
|
||||
// 必須滿足所有權限
|
||||
import { CanAll } from "@/Components/Permission/Can";
|
||||
|
||||
<CanAll permissions={['products.edit', 'products.delete']}>
|
||||
<button>完整管理</button>
|
||||
</CanAll>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 通知訊息規範
|
||||
|
||||
### 10.1 使用 Toast 通知
|
||||
|
||||
使用 `sonner` 的 `toast` 進行通知:
|
||||
|
||||
```tsx
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 成功訊息
|
||||
toast.success('操作成功');
|
||||
|
||||
// 錯誤訊息
|
||||
toast.error('操作失敗');
|
||||
|
||||
// 資訊訊息
|
||||
toast.info('提示訊息');
|
||||
|
||||
// 警告訊息
|
||||
toast.warning('警告訊息');
|
||||
```
|
||||
|
||||
### 10.2 常見操作的 Toast 訊息
|
||||
|
||||
```tsx
|
||||
// 新增成功
|
||||
router.post(route('resource.store'), data, {
|
||||
onSuccess: () => toast.success('新增成功'),
|
||||
onError: () => toast.error('新增失敗,請檢查輸入內容'),
|
||||
});
|
||||
|
||||
// 更新成功
|
||||
router.put(route('resource.update', id), data, {
|
||||
onSuccess: () => toast.success('更新成功'),
|
||||
onError: () => toast.error('更新失敗'),
|
||||
});
|
||||
|
||||
// 刪除成功
|
||||
router.delete(route('resource.destroy', id), {
|
||||
onSuccess: () => toast.success('已刪除'),
|
||||
onError: () => toast.error('刪除失敗,請檢查權限'),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 表單規範
|
||||
|
||||
### 11.1 表單容器
|
||||
|
||||
```tsx
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* 表單欄位 */}
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 11.2 表單欄位
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
欄位名稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.field}
|
||||
onChange={(e) => setData("field", e.target.value)}
|
||||
placeholder="請輸入..."
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||
/>
|
||||
{errors.field && <p className="mt-1 text-sm text-red-500">{errors.field}</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 11.3 下拉選單
|
||||
|
||||
使用 `SearchableSelect` 元件:
|
||||
|
||||
```tsx
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
<SearchableSelect
|
||||
value={data.category_id}
|
||||
onValueChange={(value) => setData("category_id", value)}
|
||||
options={categories.map(cat => ({ label: cat.name, value: String(cat.id) }))}
|
||||
placeholder="請選擇分類"
|
||||
searchThreshold={10} // 超過 10 個選項才顯示搜尋框
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11.4 對話框 (Dialog) 滾動與佈局
|
||||
|
||||
當對話框內容可能超出螢幕高度時(如長表單或詳細資料),**請勿使用 `ScrollArea`**,應直接在 `DialogContent` 使用原生的 `overflow-y-auto`。
|
||||
|
||||
**原因**:`ScrollArea` 在 Flex 佈局計算高度時容易失效或導致雙重滾動條。以及與原生捲動行為不一致。
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤:使用 ScrollArea 或固定高度計算
|
||||
<DialogContent className="max-w-3xl">
|
||||
<ScrollArea className="h-[500px]">
|
||||
{/* 內容 */}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
|
||||
// ✅ 正確:直接使用 overflow-y-auto 與 max-h
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>...</DialogHeader>
|
||||
<form className="p-6">
|
||||
{/* 內容會自動滾動 */}
|
||||
</form>
|
||||
<DialogFooter>...</DialogFooter>
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11.5 輸入框尺寸 (Input Sizes)
|
||||
|
||||
為確保介面整齊與統一,所有表單輸入元件標準高度應為 **`h-9`** (36px),與標準按鈕尺寸對齊。
|
||||
|
||||
- **Input**: 預設即為 `h-9` (由 `py-1` 與 `text-sm` 組合而成)
|
||||
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
||||
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
||||
|
||||
## 11.6 日期輸入框樣式 (Date Input Style)
|
||||
|
||||
日期輸入框應採用「**左側裝飾圖示 + 右側原生操作**」的配置,以保持視覺一致性並保留瀏覽器原生便利性。
|
||||
|
||||
**樣式規格**:
|
||||
1. **容器**: 使用 `relative` 定位。
|
||||
2. **圖標**: 使用 `Calendar` 圖標,放置於絕對位置 `absolute left-2.5 top-2.5`,顏色 `text-gray-400`,並設定 `pointer-events-none` 避免干擾點擊。
|
||||
3. **輸入框**: 設定 `pl-9` (左內距) 以避開圖示,並使用原生 `type="date"` 或 `type="datetime-local"`。
|
||||
|
||||
```tsx
|
||||
import { Calendar } from "lucide-react";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
className="pl-9 block w-full"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 11.7 搜尋選單樣式 (SearchableSelect Style)
|
||||
|
||||
`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。
|
||||
|
||||
```tsx
|
||||
<SearchableSelect
|
||||
className="h-9" // 確保高度一致
|
||||
// ...other props
|
||||
/>
|
||||
```
|
||||
|
||||
## 11.8 篩選列規範 (Filter Bar Norms)
|
||||
|
||||
列表頁面的篩選區域(Filter Bar)應遵循以下規範以節省空間並保持層級清晰:
|
||||
|
||||
1. **標籤文字 (Labels)**: 使用 **`text-xs`** (`12px`) 大小,顏色建議使用 `text-gray-500` 或 `text-grey-2`。這與一般表單 (`text-sm`) 不同,目的是降低篩選列的視覺權重。
|
||||
2. **輸入元件高度**: 統一使用 **`h-9`** (`36px`)。
|
||||
3. **佈局**:
|
||||
- **容器內距**: 統一使用 **`p-5`** (`20px`)。
|
||||
- **Grid 間距**: 建議使用 **`gap-4`** (`16px`) 或 `gap-6` (`24px`),但同一專案內需統一。本專案推薦 **`gap-4`**。
|
||||
- **垂直間距**: Label 與 Input 之間使用 **`space-y-1`** (`4px`)。
|
||||
- **排版**: 建議使用 Grid 系統 (`grid-cols-12`) 進行排版。
|
||||
|
||||
```tsx
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
|
||||
<Input className="h-9" placeholder="..." />
|
||||
</div>
|
||||
```
|
||||
|
||||
4. **操作按鈕區 (Action Bar)**:
|
||||
- **位置**: 位於篩選列最下方。
|
||||
- **樣式**: 統一使用 `flex items-center justify-end border-t border-grey-4 pt-5 gap-3`。
|
||||
- **說明**: `border-grey-4` 為標準通用邊框色,`pt-5` 與容器 padding (`p-5`) 呼應,維持視覺平衡。
|
||||
|
||||
5. **收合模式 (Collapsible Mode)**:
|
||||
- **目的**: 節省垂直空間,預設隱藏較佔空間與低頻使用的篩選器(如日期區間)。
|
||||
- **實作**:
|
||||
- 預設狀態:若無相關篩選值,則預設為 **收合 (Collapsed)**。
|
||||
- 切換按鈕:位於 Action Bar 左側 (`mr-auto`)。
|
||||
- 樣式:Ghost Button + `ChevronDown`/`ChevronUp` Icon + 提示圓點 (Indicator)。
|
||||
- **邏輯**: 若載入頁面時已有被隱藏的篩選值 (e.g. `date_start`),則強制 **展開 (Expanded)** 或顯示提示。
|
||||
|
||||
---
|
||||
|
||||
## 12. 檢查清單
|
||||
|
||||
在開發或審查頁面時,請確認以下項目:
|
||||
|
||||
### ✅ 按鈕
|
||||
- [ ] 使用 `button-filled-*` 或 `button-outlined-*` 類別
|
||||
- [ ] 主要操作使用 `button-filled-primary`
|
||||
- [ ] 編輯操作使用 `button-outlined-primary`
|
||||
- [ ] 刪除操作使用 `button-outlined-error`
|
||||
- [ ] 按鈕尺寸正確(sm/default/lg)
|
||||
- [ ] 包含適當的圖標
|
||||
|
||||
### ✅ 圖標
|
||||
- [ ] 全部使用 `lucide-react`
|
||||
- [ ] 尺寸正確(h-3/h-4/h-5/h-6)
|
||||
- [ ] 顏色與上下文一致
|
||||
|
||||
### ✅ 表格
|
||||
- [ ] 使用 `@/Components/ui/table` 元件
|
||||
- [ ] 有 `bg-white rounded-xl border` 容器
|
||||
- [ ] 標題列有 `bg-gray-50` 背景
|
||||
- [ ] 序號欄固定寬度並置中
|
||||
- [ ] 操作欄使用 `flex justify-center gap-2`
|
||||
- [ ] 空狀態訊息置中顯示
|
||||
|
||||
### ✅ 分頁
|
||||
- [ ] 使用 `@/Components/shared/Pagination`
|
||||
- [ ] 有每頁筆數選擇器(10/20/50/100)
|
||||
|
||||
### ✅ 權限
|
||||
- [ ] 所有操作按鈕都用 `<Can>` 包裹
|
||||
- [ ] 權限命名符合 `resource.action` 格式
|
||||
|
||||
### ✅ 通知
|
||||
- [ ] 使用 `toast` 提供操作反饋
|
||||
- [ ] 成功/錯誤訊息明確
|
||||
|
||||
### ✅ 整體
|
||||
- [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕)
|
||||
- [ ] 容器寬度使用 `max-w-7xl`
|
||||
- [ ] 使用正確的佈局(`AuthenticatedLayout`)
|
||||
|
||||
---
|
||||
|
||||
## 13. 常見錯誤與修正
|
||||
|
||||
### ❌ 錯誤:自定義按鈕樣式
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
<Button className="bg-green-500 text-white hover:bg-green-600">
|
||||
新增
|
||||
</Button>
|
||||
|
||||
// ✅ 正確
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增
|
||||
</Button>
|
||||
```
|
||||
|
||||
### ❌ 錯誤:混用圖標庫
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
import { FaEdit } from 'react-icons/fa';
|
||||
<FaEdit />
|
||||
|
||||
// ✅ 正確
|
||||
import { Pencil } from 'lucide-react';
|
||||
<Pencil className="h-4 w-4" />
|
||||
```
|
||||
|
||||
### ❌ 錯誤:操作欄未置中
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
<TableCell>
|
||||
<Button>編輯</Button>
|
||||
<Button>刪除</Button>
|
||||
</TableCell>
|
||||
|
||||
// ✅ 正確
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="button-outlined-error">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
```
|
||||
|
||||
### ❌ 錯誤:缺少權限控制
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
<Button onClick={handleDelete}>刪除</Button>
|
||||
|
||||
// ✅ 正確
|
||||
<Can permission="resource.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Can>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 參考範例
|
||||
|
||||
以下頁面展示了完整的 UI 統一性實踐:
|
||||
|
||||
- **使用者管理**:`resources/js/Pages/Admin/User/Index.tsx`
|
||||
- **角色管理**:`resources/js/Pages/Admin/Role/Index.tsx`
|
||||
- **產品管理**:`resources/js/Pages/Product/Index.tsx`
|
||||
- **倉庫管理**:`resources/js/Pages/Warehouse/Index.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 總結
|
||||
|
||||
遵循本規範可確保:
|
||||
|
||||
1. ✅ **視覺一致性**:所有頁面看起來像同一個系統
|
||||
2. ✅ **維護效率**:使用統一組件,修改一處即可影響全局
|
||||
3. ✅ **開發速度**:有明確的模式可循,減少決策時間
|
||||
4. ✅ **使用者體驗**:一致的互動模式降低學習成本
|
||||
5. ✅ **安全性**:統一的權限控制確保資料安全
|
||||
|
||||
當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範!
|
||||
100
.gitea/workflows/deploy-demo.yaml
Normal file
100
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,100 @@
|
||||
name: ERP-Deploy-Demo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- demo
|
||||
|
||||
jobs:
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.taiwan-star.com.tw
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Demo
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||
chmod 600 ~/.ssh/id_rsa_demo
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='vendor' \
|
||||
--exclude='storage' \
|
||||
--exclude='.env' \
|
||||
--exclude='public/build' \
|
||||
-e "ssh -p 2227 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||
./ root@220.132.7.82:/var/www/star-erp-demo/
|
||||
rm ~/.ssh/id_rsa_demo
|
||||
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp-demo
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp-demo
|
||||
chown -R 1000:1000 .
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||
|
||||
- name: Step 4 - Composer & NPM Build
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
npm install &&
|
||||
npm run build &&
|
||||
rm -f public/hot &&
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan tenants:migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
|
||||
php artisan permission:cache-reset &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
"
|
||||
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
93
.gitea/workflows/deploy-prod.yaml
Normal file
93
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
name: ERP-Deploy-Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Production
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_rsa_prod
|
||||
chmod 600 ~/.ssh/id_rsa_prod
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='vendor' \
|
||||
--exclude='storage' \
|
||||
--exclude='public/build' \
|
||||
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
||||
./ root@220.132.7.82:/var/www/star-erp/
|
||||
rm ~/.ssh/id_rsa_prod
|
||||
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild_prod
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2224
|
||||
username: root
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2224
|
||||
username: root
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp
|
||||
chown -R 1000:1000 .
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.prod\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||
|
||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||
composer install --no-dev --optimize-autoloader &&
|
||||
npm install &&
|
||||
npm run build &&
|
||||
rm -f public/hot
|
||||
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan tenants:migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
|
||||
php artisan permission:cache-reset &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
"
|
||||
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
@@ -1,200 +0,0 @@
|
||||
name: Koori-ERP-Deploy-System
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- demo
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# --- 1. Demo 環境部署 (103 本機) ---
|
||||
deploy-demo:
|
||||
if: github.ref == 'refs/heads/demo'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: http://192.168.0.103:3000
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Demo
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||
chmod 600 ~/.ssh/id_rsa_demo
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='vendor' \
|
||||
--exclude='storage' \
|
||||
--exclude='.env' \
|
||||
--exclude='public/build' \
|
||||
-e "ssh -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||
./ amba@192.168.0.103:/home/amba/star-erp/
|
||||
rm ~/.ssh/id_rsa_demo
|
||||
|
||||
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 192.168.0.103
|
||||
port: 22
|
||||
username: amba
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /home/amba/star-erp
|
||||
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
# 3. 啟動或重建容器(根據檢查結果決定是否加 --build)
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 192.168.0.103
|
||||
port: 22
|
||||
username: amba
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /home/amba/koori-erp
|
||||
chown -R 1000:1000 .
|
||||
|
||||
# 檢查是否需要重建
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
# 確保容器正在運行(若未運行則啟動)
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'koori-erp-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=koori-erp-laravel"
|
||||
|
||||
|
||||
- name: Step 4 - Composer & NPM Build
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 192.168.0.103
|
||||
port: 22
|
||||
username: amba
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||
# 1. 後端依賴 (Demo 環境建議加上 --no-interaction 避免卡住)
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
|
||||
# 2. 前端編譯
|
||||
npm install &&
|
||||
npm run build &&
|
||||
|
||||
# 3. Laravel 初始化與優化
|
||||
php artisan migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
"
|
||||
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
# --- 2. 正式環境部署 (erp.koori.tw:2224) ---
|
||||
deploy-production:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: http://192.168.0.103:3000
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Production
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_rsa_prod
|
||||
chmod 600 ~/.ssh/id_rsa_prod
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='vendor' \
|
||||
--exclude='public/build' \
|
||||
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
||||
./ root@erp.koori.tw:/var/www/star-erp/
|
||||
rm ~/.ssh/id_rsa_prod
|
||||
|
||||
|
||||
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild_prod
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: erp.koori.tw
|
||||
port: 2224
|
||||
username: root
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp
|
||||
# [Patch] 修正正式機 Nginx Proxy 配置 (對應外部 SSL/OpenResty)
|
||||
sed -i "s/- '8080:8080'/- '80:80'\n - '8080:8080'/" compose.yaml
|
||||
sed -i "s/demo-proxy.conf/prod-proxy.conf/" compose.yaml
|
||||
|
||||
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
# 3. 啟動或重建容器(根據檢查結果決定是否加 --build)
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: erp.koori.tw
|
||||
port: 2224
|
||||
username: root
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp
|
||||
chown -R 1000:1000 .
|
||||
|
||||
# 檢查是否需要重建
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
# 確保容器正在運行(若未運行則啟動)
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||
|
||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||
composer install --no-dev --optimize-autoloader &&
|
||||
npm install &&
|
||||
npm run build
|
||||
|
||||
php artisan migrate --force &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
"
|
||||
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ Thumbs.db
|
||||
智慧補貨系統分析報告.md
|
||||
|
||||
/docs/pptx_build
|
||||
/docs/presentation
|
||||
docs/Monthly_Report_2026_01.pptx
|
||||
docs/f6_1770350984272.xlsx
|
||||
|
||||
@@ -13,7 +13,7 @@ Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind
|
||||
|
||||
## 📂 系統功能詳細說明
|
||||
|
||||
### 🌳 系統功能架構樹 (含 2.0 升級規劃)
|
||||
### 🌳 預計系統功能架構樹 (含 2.0 升級規劃)
|
||||
```text
|
||||
Star ERP
|
||||
├── 🏠 儀表板 (Dashboard)
|
||||
@@ -107,6 +107,7 @@ Star ERP
|
||||
git clone <repository_url> star-erp
|
||||
cd star-erp
|
||||
|
||||
|
||||
# 2. 設定環境變數
|
||||
cp .env.example .env
|
||||
# 請檢查 .env 內容,本機開發預設配置:
|
||||
@@ -171,7 +172,6 @@ docker exec -it star-erp-laravel php artisan tinker
|
||||
# 停止容器
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 🧪 開發規範
|
||||
|
||||
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
|
||||
@@ -180,3 +180,4 @@ docker compose down
|
||||
- **多租戶**:
|
||||
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
||||
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class TenantController extends Controller
|
||||
return [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||
@@ -47,6 +48,7 @@ class TenantController extends Controller
|
||||
$validated = $request->validate([
|
||||
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'short_name' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:100'],
|
||||
'domain' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
@@ -54,8 +56,14 @@ class TenantController extends Controller
|
||||
$tenant = Tenant::create([
|
||||
'id' => $validated['id'],
|
||||
'name' => $validated['name'],
|
||||
'short_name' => $validated['short_name'] ?? null,
|
||||
'email' => $validated['email'] ?? null,
|
||||
'is_active' => true,
|
||||
'branding' => [
|
||||
'logo_path' => 'defaults/logo.png', // 預設 Logo 路徑
|
||||
'login_background_path' => 'defaults/login_bg.jpg', // 預設登入背景
|
||||
'primary_color' => '#4F46E5', // 預設主色系 (Indigo-600)
|
||||
],
|
||||
]);
|
||||
|
||||
// 綁定網域(如果沒有輸入,使用預設網域)
|
||||
@@ -76,10 +84,29 @@ class TenantController extends Controller
|
||||
{
|
||||
$tenant = Tenant::with('domains')->findOrFail($id);
|
||||
|
||||
$tokens = [];
|
||||
try {
|
||||
tenancy()->initialize($tenant);
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
if ($user) {
|
||||
$tokens = $user->tokens()->orderBy('created_at', 'desc')->get(['id', 'name', 'last_used_at', 'created_at'])->map(function($token) {
|
||||
return [
|
||||
'id' => $token->id,
|
||||
'name' => $token->name,
|
||||
'last_used_at' => $token->last_used_at ? $token->last_used_at->format('Y-m-d H:i') : '未使用',
|
||||
'created_at' => $token->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning("Failed to fetch tokens for tenant {$id}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return Inertia::render('Landlord/Tenant/Show', [
|
||||
'tenant' => [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||
@@ -89,6 +116,7 @@ class TenantController extends Controller
|
||||
'domain' => $d->domain,
|
||||
])->toArray(),
|
||||
],
|
||||
'tokens' => $tokens,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,6 +151,7 @@ class TenantController extends Controller
|
||||
'tenant' => [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
],
|
||||
@@ -138,6 +167,7 @@ class TenantController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'short_name' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:100'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
@@ -231,4 +261,58 @@ class TenantController extends Controller
|
||||
|
||||
return redirect()->back()->with('success', '樣式設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 API Token (用於 POS)
|
||||
*/
|
||||
public function createToken(Request $request, Tenant $tenant)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
try {
|
||||
// 切換至租戶環境
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// 尋找超級管理員 (假設 ID 1, 或者根據 Role)
|
||||
// 這裡簡單取第一個使用者,通常是 Admin
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
|
||||
if (!$user) {
|
||||
return back()->with('error', '該租戶尚無使用者,無法建立 Token。');
|
||||
}
|
||||
|
||||
// 建立 Token
|
||||
$token = $user->createToken($request->name);
|
||||
|
||||
return back()->with('success', 'Token 建立成功')->with('new_token', $token->plainTextToken);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Token creation failed: " . $e->getMessage());
|
||||
return back()->with('error', 'Token 建立失敗');
|
||||
} finally {
|
||||
// tenancy()->end(); // Laravel Tenancy 自動處理 scope 結束? 通常 Controller request life-cycle?
|
||||
// Landlord controller is Central. Tenancy initialization persists for request.
|
||||
// We should explicit end if we want to be safe, but redirect ends request anyway.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤銷 API Token
|
||||
*/
|
||||
public function revokeToken(Request $request, Tenant $tenant, string $tokenId)
|
||||
{
|
||||
try {
|
||||
tenancy()->initialize($tenant);
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
|
||||
if ($user) {
|
||||
$user->tokens()->where('id', $tokenId)->delete();
|
||||
}
|
||||
|
||||
return back()->with('success', 'Token 已撤銷');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Token 撤銷失敗');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,16 @@ class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$tenant = tenancy()->tenant;
|
||||
$appName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
|
||||
|
||||
// 分享給 Blade View (給 app.blade.php 使用)
|
||||
\Illuminate\Support\Facades\View::share('appName', $appName);
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'appName' => $appName,
|
||||
'app_version' => config('app.version'),
|
||||
'auth' => [
|
||||
'user' => $user ? [
|
||||
'id' => $user->id,
|
||||
@@ -54,23 +62,40 @@ class HandleInertiaRequests extends Middleware
|
||||
'flash' => [
|
||||
'success' => $request->session()->get('success'),
|
||||
'error' => $request->session()->get('error'),
|
||||
'new_token' => $request->session()->get('new_token'),
|
||||
],
|
||||
'branding' => function () {
|
||||
$tenant = tenancy()->tenant;
|
||||
if (!$tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 決定名稱顯示邏輯
|
||||
$fullName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
|
||||
$shortName = $tenant ? ($tenant->short_name ?? $fullName) : 'Start ERP';
|
||||
|
||||
$logoUrl = null;
|
||||
if (isset($tenant->branding['logo_path'])) {
|
||||
if ($tenant && isset($tenant->branding['logo_path'])) {
|
||||
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
||||
} elseif (!$tenant) {
|
||||
$logoUrl = \Storage::url('defaults/logo.png');
|
||||
}
|
||||
|
||||
return [
|
||||
$brandingData = [
|
||||
'name' => $fullName,
|
||||
'short_name' => $shortName,
|
||||
'logo_url' => $logoUrl,
|
||||
'primary_color' => $tenant->branding['primary_color'] ?? '#01ab83',
|
||||
'primary_color' => $tenant->branding['primary_color'] ?? ($tenant ? '#01ab83' : '#4F46E5'),
|
||||
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
|
||||
];
|
||||
|
||||
// 同步分享給 Blade View (給 app.blade.php 使用 Favicon)
|
||||
\Illuminate\Support\Facades\View::share('branding', $brandingData);
|
||||
|
||||
return $brandingData;
|
||||
},
|
||||
'notifications' => function () use ($request) {
|
||||
return $request->user() ? [
|
||||
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
] : null;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
|
||||
'App\Modules\Inventory\Models\Inventory' => '庫存',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
|
||||
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
|
||||
'App\Modules\Production\Models\Recipe' => '生產配方',
|
||||
'App\Modules\Production\Models\RecipeItem' => '配方品項',
|
||||
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
|
||||
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
|
||||
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
|
||||
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -76,6 +84,7 @@ class ActivityLogController extends Controller
|
||||
}
|
||||
|
||||
$activities = $query->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($activity) {
|
||||
$subjectMap = $this->getSubjectMap();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
@@ -42,17 +43,27 @@ class LoginController extends Controller
|
||||
$credentials = $request->only('username', 'password');
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
// Check activation status
|
||||
if (!Auth::user()->is_active) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'username' => '此帳號已被停用,請聯繫管理員。',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
// [Hack] Demo 環境特殊規則
|
||||
$demoPort = config('tenancy.demo_tenant_port');
|
||||
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
|
||||
return redirect()->intended(route('landlord.dashboard'));
|
||||
return Inertia::location(route('landlord.dashboard'));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
return Inertia::location(route('dashboard'));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
@@ -70,6 +81,10 @@ class LoginController extends Controller
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// 強制清除 Session Cookie (對付 HTTPS/Proxy 環境下的殘留問題)
|
||||
$sessionCookieName = config('session.cookie');
|
||||
Cookie::queue(Cookie::forget($sessionCookieName));
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
@@ -34,18 +34,120 @@ class DashboardController extends Controller
|
||||
$invStats = $this->inventoryService->getDashboardStats();
|
||||
$procStats = $this->procurementService->getDashboardStats();
|
||||
|
||||
$stats = [
|
||||
'productsCount' => $invStats['productsCount'],
|
||||
'vendorsCount' => $procStats['vendorsCount'],
|
||||
'purchaseOrdersCount' => $procStats['purchaseOrdersCount'],
|
||||
'warehousesCount' => $invStats['warehousesCount'],
|
||||
'totalInventoryValue' => $invStats['totalInventoryQuantity'], // 原本前端命名是 totalInventoryValue 但實作是 Quantity,暫且保留欄位名以不破壞前端
|
||||
'pendingOrdersCount' => $procStats['pendingOrdersCount'],
|
||||
'lowStockCount' => $invStats['lowStockCount'],
|
||||
];
|
||||
// 銷售統計 (本月營收)
|
||||
$thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month)
|
||||
->whereYear('transaction_at', now()->year)
|
||||
->sum('amount');
|
||||
|
||||
// 生產統計 (待核准工單)
|
||||
$pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count();
|
||||
|
||||
// 生產狀態分佈
|
||||
// 近30日銷售趨勢 (Area Chart)
|
||||
$startDate = now()->subDays(29)->startOfDay();
|
||||
$salesData = \App\Modules\Sales\Models\SalesImportItem::where('transaction_at', '>=', $startDate)
|
||||
->selectRaw('DATE(transaction_at) as date, SUM(amount) as total')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get()
|
||||
->mapWithKeys(function ($item) {
|
||||
return [$item->date => (int)$item->total];
|
||||
});
|
||||
|
||||
$salesTrend = [];
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$date = $startDate->copy()->addDays($i)->format('Y-m-d');
|
||||
$salesTrend[] = [
|
||||
'date' => $startDate->copy()->addDays($i)->format('m/d'),
|
||||
'amount' => $salesData[$date] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 本月熱銷商品 Top 5 (Bar Chart)
|
||||
$topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product')
|
||||
->whereMonth('transaction_at', now()->month)
|
||||
->whereYear('transaction_at', now()->year)
|
||||
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount'))
|
||||
->groupBy('product_code', 'product_id')
|
||||
->orderByDesc('total_amount')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : $item->product_code,
|
||||
'amount' => (int)$item->total_amount,
|
||||
];
|
||||
});
|
||||
|
||||
// 庫存積壓排行 (Top Inventory Value)
|
||||
$topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product')
|
||||
->select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value'))
|
||||
->where('quantity', '>', 0)
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('total_value')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : 'Unknown Product',
|
||||
'code' => $item->product ? $item->product->code : '',
|
||||
'value' => (int)$item->total_value,
|
||||
];
|
||||
});
|
||||
|
||||
// 熱銷數量排行 (Top Selling by Quantity)
|
||||
$topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product')
|
||||
->whereMonth('transaction_at', now()->month)
|
||||
->whereYear('transaction_at', now()->year)
|
||||
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity'))
|
||||
->groupBy('product_code', 'product_id')
|
||||
->orderByDesc('total_quantity')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : $item->product_code,
|
||||
'code' => $item->product_code,
|
||||
'value' => (int)$item->total_quantity,
|
||||
];
|
||||
});
|
||||
|
||||
// 即將過期商品 (Expiring Soon)
|
||||
$expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product')
|
||||
->where('quantity', '>', 0)
|
||||
->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
|
||||
->orderBy('expiry_date', 'asc')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : 'Unknown Product',
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $item->expiry_date->format('Y-m-d'),
|
||||
'quantity' => (int)$item->quantity,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'stats' => $stats,
|
||||
'stats' => [
|
||||
'totalItems' => $invStats['productsCount'],
|
||||
'lowStockCount' => $invStats['lowStockCount'],
|
||||
'negativeCount' => $invStats['negativeCount'] ?? 0,
|
||||
'expiringCount' => $invStats['expiringCount'] ?? 0,
|
||||
'totalInventoryValue' => $invStats['totalInventoryValue'] ?? 0,
|
||||
'thisMonthRevenue' => $thisMonthRevenue,
|
||||
'pendingOrdersCount' => $procStats['pendingOrdersCount'] ?? 0,
|
||||
'pendingTransferCount' => $invStats['pendingTransferCount'] ?? 0,
|
||||
'pendingProductionCount' => $pendingProductionCount,
|
||||
'todoCount' => ($procStats['pendingOrdersCount'] ?? 0) + ($invStats['pendingTransferCount'] ?? 0) + $pendingProductionCount,
|
||||
'salesTrend' => $salesTrend,
|
||||
'topSellingProducts' => $topSellingProducts,
|
||||
'topInventoryValue' => $topInventoryValue,
|
||||
'topSellingByQuantity' => $topSellingByQuantity,
|
||||
'expiringSoon' => $expiringSoon,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
app/Modules/Core/Controllers/NotificationController.php
Normal file
41
app/Modules/Core/Controllers/NotificationController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark a specific notification as read.
|
||||
*/
|
||||
public function markAsRead(Request $request, string $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read.
|
||||
*/
|
||||
public function markAllAsRead(Request $request)
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for new notifications.
|
||||
*/
|
||||
public function check(Request $request)
|
||||
{
|
||||
return response()->json([
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
|
||||
@@ -123,7 +123,7 @@ class RoleController extends Controller
|
||||
$role->syncPermissions($validated['permissions']);
|
||||
}
|
||||
|
||||
return redirect()->route('roles.index')->with('success', '角色更新成功');
|
||||
return back()->with('success', '角色更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,8 +160,13 @@ class RoleController extends Controller
|
||||
$action = $parts[1] ?? '';
|
||||
|
||||
// 特定權限遷移邏輯
|
||||
if ($permission->name === 'inventory.transfer') {
|
||||
$group = 'warehouses'; // 調撥功能移至倉庫管理下
|
||||
if ($permission->name === 'inventory.view_cost') {
|
||||
$group = 'inventory';
|
||||
}
|
||||
|
||||
// 移除不再使用的權限選項
|
||||
if (in_array($permission->name, ['inventory.count', 'inventory.transfer'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($grouped[$group])) {
|
||||
@@ -175,13 +180,24 @@ class RoleController extends Controller
|
||||
$groupDefinitions = [
|
||||
'products' => '商品資料管理',
|
||||
'warehouses' => '倉庫管理',
|
||||
'inventory' => '庫存管理',
|
||||
'inventory' => '庫存資料管理',
|
||||
'inventory_count' => '庫存盤點管理',
|
||||
'inventory_adjust' => '庫存盤調管理',
|
||||
'inventory_transfer' => '庫存調撥管理',
|
||||
'inventory_report' => '庫存報表',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
'delivery_notes' => '出貨單管理',
|
||||
'recipes' => '配方管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
'sales_imports' => '銷售單匯入管理',
|
||||
'store_requisitions' => '門市叫貨申請',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'system' => '系統管理',
|
||||
];
|
||||
|
||||
$result = [];
|
||||
|
||||
@@ -22,9 +22,26 @@ class UserController extends Controller
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
$search = $request->input('search');
|
||||
$roleId = $request->input('role');
|
||||
|
||||
$query = User::with(['roles:id,name,display_name']);
|
||||
$roleId = $request->input('role');
|
||||
$isActive = $request->input('is_active'); // 'all', '1', '0'
|
||||
|
||||
$query = User::query();
|
||||
|
||||
// 隱藏超級管理員:若非 super-admin,則不可看到 super-admin 過往
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$query->whereDoesntHave('roles', function ($q) {
|
||||
$q->where('name', 'super-admin');
|
||||
});
|
||||
|
||||
// 預載入角色時也過濾掉 super-admin 標籤
|
||||
$query->with(['roles' => function ($q) {
|
||||
$q->select('id', 'name', 'display_name')
|
||||
->where('name', '!=', 'super-admin');
|
||||
}]);
|
||||
} else {
|
||||
$query->with(['roles:id,name,display_name']);
|
||||
}
|
||||
|
||||
// 處理搜尋
|
||||
if ($search) {
|
||||
@@ -42,6 +59,11 @@ class UserController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// 處理狀態篩選
|
||||
if ($isActive !== null && $isActive !== 'all') {
|
||||
$query->where('is_active', $isActive === '1' || $isActive === 'true');
|
||||
}
|
||||
|
||||
// 處理排序
|
||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
@@ -50,12 +72,19 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$users = $query->paginate($perPage)->withQueryString();
|
||||
$roles = Role::select('id', 'name', 'display_name')->get();
|
||||
|
||||
// 只能看到自己權限以下的角色
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Index', [
|
||||
'users' => $users,
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role']),
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role', 'is_active']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -64,7 +93,11 @@ class UserController extends Controller
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$roles = Role::pluck('display_name', 'name');
|
||||
$rolesQuery = Role::query();
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->pluck('display_name', 'name');
|
||||
|
||||
return Inertia::render('Admin/User/Create', [
|
||||
'roles' => $roles
|
||||
@@ -80,8 +113,10 @@ class UserController extends Controller
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users'],
|
||||
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.required' => '請輸入密碼',
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
@@ -92,10 +127,16 @@ class UserController extends Controller
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
|
||||
'password' => Hash::make($validated['password']),
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
if (!empty($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
$user->syncRoles($validated['roles']);
|
||||
|
||||
// 更新 'created' 紀錄以包含角色資訊
|
||||
@@ -123,7 +164,17 @@ class UserController extends Controller
|
||||
public function edit(string $id)
|
||||
{
|
||||
$user = User::with('roles')->findOrFail($id);
|
||||
$roles = Role::get(['id', 'name', 'display_name']);
|
||||
|
||||
// 安全檢查:非 super-admin 不能編輯 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Edit', [
|
||||
'user' => $user,
|
||||
@@ -139,12 +190,19 @@ class UserController extends Controller
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能更新 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
'password.confirmed' => '密碼確認不符',
|
||||
@@ -157,10 +215,6 @@ class UserController extends Controller
|
||||
'username' => $validated['username'],
|
||||
];
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$userData['password'] = Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
$user->fill($userData);
|
||||
|
||||
// 捕捉變更屬性以進行手動記錄
|
||||
@@ -179,6 +233,11 @@ class UserController extends Controller
|
||||
// 2. 處理角色
|
||||
$roleChanges = null;
|
||||
if (isset($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
|
||||
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
$user->syncRoles($validated['roles']);
|
||||
$newRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
@@ -230,6 +289,11 @@ class UserController extends Controller
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能刪除 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限刪除系統管理員');
|
||||
}
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return back()->with('error', '無法刪除超級管理員帳號');
|
||||
}
|
||||
@@ -240,6 +304,46 @@ class UserController extends Controller
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', '使用者已刪除');
|
||||
return redirect()->route('users.index')->with('success', "使用者「{$user->name}」已刪除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換使用者啟用/停用狀態
|
||||
*/
|
||||
public function toggleActive(string $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:不能停用自己
|
||||
if ($user->id === auth()->id() && $user->is_active) {
|
||||
return back()->with('error', '無法停用自己的帳號');
|
||||
}
|
||||
|
||||
// 安全檢查:非 super-admin 不能停用 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限變更系統管理員狀態');
|
||||
}
|
||||
|
||||
$oldStatus = $user->is_active;
|
||||
$user->is_active = !$oldStatus;
|
||||
$user->save();
|
||||
|
||||
// 記錄活動
|
||||
activity()
|
||||
->performedOn($user)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => ['is_active' => $user->is_active],
|
||||
'old' => ['is_active' => $oldStatus],
|
||||
'snapshot' => [
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
|
||||
$statusText = $user->is_active ? '已啟用' : '已停用';
|
||||
return back()->with('success', "使用者「{$user->name}」{$statusText}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
||||
use HasFactory, Notifiable, HasRoles, LogsActivity, HasApiTokens;
|
||||
|
||||
/**
|
||||
* 可批量賦值的屬性。
|
||||
@@ -35,6 +37,7 @@ class User extends Authenticatable
|
||||
'email',
|
||||
'username',
|
||||
'password',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,7 +59,9 @@ class User extends Authenticatable
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ Route::post('/login', [LoginController::class, 'store']);
|
||||
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
// 通知
|
||||
Route::post('/notifications/read-all', [\App\Modules\Core\Controllers\NotificationController::class, 'markAllAsRead'])->name('notifications.read-all');
|
||||
Route::post('/notifications/{id}/read', [\App\Modules\Core\Controllers\NotificationController::class, 'markAsRead'])->name('notifications.read');
|
||||
Route::get('/notifications/check', [\App\Modules\Core\Controllers\NotificationController::class, 'check'])->name('notifications.check');
|
||||
|
||||
// 儀表板 - 所有登入使用者皆可存取
|
||||
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
@@ -43,6 +48,7 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
|
||||
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
||||
Route::patch('/users/{user}/toggle-active', [UserController::class, 'toggleActive'])->middleware('permission:users.activate')->name('users.toggle-active');
|
||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
|
||||
});
|
||||
|
||||
|
||||
156
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
156
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Actions;
|
||||
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use App\Modules\Integration\Models\SalesOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SyncOrderAction
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $productService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProductServiceInterface $productService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->productService = $productService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行訂單同步
|
||||
*
|
||||
* @param array $data
|
||||
* @return array 包含 orders 建立結果的資訊
|
||||
* @throws ValidationException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function execute(array $data)
|
||||
{
|
||||
$externalOrderId = $data['external_order_id'];
|
||||
|
||||
// 使用 Cache::lock 防護高併發,鎖定該訂單號 10 秒
|
||||
// 此處需要 cache store 支援鎖 (如 memcached, dynamodb, redis, database, file, array)
|
||||
// Laravel 預設的 file/redis 都支援。若無法取得鎖,表示有另一個相同的請求正在處理
|
||||
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
|
||||
|
||||
if (!$lock->get()) {
|
||||
throw ValidationException::withMessages([
|
||||
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
|
||||
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
|
||||
if ($existingOrder) {
|
||||
return [
|
||||
'status' => 'exists',
|
||||
'message' => 'Order already exists',
|
||||
'order_id' => $existingOrder->id,
|
||||
];
|
||||
}
|
||||
|
||||
// --- 預檢 (Pre-flight check) N+1 優化 ---
|
||||
$items = $data['items'];
|
||||
$posProductIds = array_column($items, 'pos_product_id');
|
||||
|
||||
// 一次性查出所有相關的 Product
|
||||
$products = $this->productService->findByExternalPosIds($posProductIds)->keyBy('external_pos_id');
|
||||
|
||||
$missingIds = [];
|
||||
foreach ($posProductIds as $id) {
|
||||
if (!$products->has($id)) {
|
||||
$missingIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingIds)) {
|
||||
// 回報所有缺漏的 ID
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["The following products are not found: " . implode(', ', $missingIds) . ". Please sync products first."]
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 執行寫入交易 ---
|
||||
$result = DB::transaction(function () use ($data, $items, $products) {
|
||||
// 1. 建立訂單
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $data['external_order_id'],
|
||||
'status' => 'completed',
|
||||
'payment_method' => $data['payment_method'] ?? 'cash',
|
||||
'total_amount' => 0,
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => $data['source'] ?? 'pos',
|
||||
'source_label' => $data['source_label'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. 查找或建立倉庫
|
||||
$warehouseId = $data['warehouse_id'] ?? null;
|
||||
|
||||
if (empty($warehouseId)) {
|
||||
$warehouseName = $data['warehouse'] ?? '銷售倉庫';
|
||||
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
|
||||
$warehouseId = $warehouse->id;
|
||||
}
|
||||
|
||||
$totalAmount = 0;
|
||||
|
||||
// 3. 處理訂單明細
|
||||
$orderItemsData = [];
|
||||
foreach ($items as $itemData) {
|
||||
$product = $products->get($itemData['pos_product_id']);
|
||||
|
||||
$qty = $itemData['qty'];
|
||||
$price = $itemData['price'];
|
||||
$lineTotal = $qty * $price;
|
||||
$totalAmount += $lineTotal;
|
||||
|
||||
$orderItemsData[] = [
|
||||
'sales_order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name,
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $lineTotal,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// 4. 扣除庫存(強制模式,允許負庫存)
|
||||
$this->inventoryService->decreaseStock(
|
||||
$product->id,
|
||||
$warehouseId,
|
||||
$qty,
|
||||
"POS Order: " . $order->external_order_id,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Batch insert order items
|
||||
SalesOrderItem::insert($orderItemsData);
|
||||
|
||||
$order->update(['total_amount' => $totalAmount]);
|
||||
|
||||
return [
|
||||
'status' => 'created',
|
||||
'message' => 'Order synced and stock deducted successfully',
|
||||
'order_id' => $order->id,
|
||||
];
|
||||
});
|
||||
|
||||
return $result;
|
||||
} finally {
|
||||
// 無論成功失敗,最後釋放鎖定
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
152
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
152
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Actions;
|
||||
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use App\Modules\Integration\Models\SalesOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SyncVendingOrderAction
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $productService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProductServiceInterface $productService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->productService = $productService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行販賣機訂單同步
|
||||
*
|
||||
* @param array $data
|
||||
* @return array 包含訂單建立結果的資訊
|
||||
* @throws ValidationException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function execute(array $data)
|
||||
{
|
||||
$externalOrderId = $data['external_order_id'];
|
||||
|
||||
// 使用 Cache::lock 防護高併發
|
||||
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
|
||||
|
||||
if (!$lock->get()) {
|
||||
throw ValidationException::withMessages([
|
||||
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
|
||||
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
|
||||
if ($existingOrder) {
|
||||
return [
|
||||
'status' => 'exists',
|
||||
'message' => 'Order already exists',
|
||||
'order_id' => $existingOrder->id,
|
||||
];
|
||||
}
|
||||
|
||||
// --- 預檢:以 ERP 商品代碼查詢 ---
|
||||
$items = $data['items'];
|
||||
$productCodes = array_column($items, 'product_code');
|
||||
|
||||
// 一次性查出所有相關的 Product(以 code 查詢)
|
||||
$products = $this->productService->findByCodes($productCodes)->keyBy('code');
|
||||
|
||||
$missingCodes = [];
|
||||
foreach ($productCodes as $code) {
|
||||
if (!$products->has($code)) {
|
||||
$missingCodes[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingCodes)) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["The following products are not found by code: " . implode(', ', $missingCodes) . ". Please ensure these products exist in the system."]
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 執行寫入交易 ---
|
||||
$result = DB::transaction(function () use ($data, $items, $products) {
|
||||
// 1. 建立訂單
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $data['external_order_id'],
|
||||
'status' => 'completed',
|
||||
'payment_method' => $data['payment_method'] ?? 'electronic',
|
||||
'total_amount' => 0,
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => 'vending',
|
||||
'source_label' => $data['machine_id'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. 查找或建立倉庫
|
||||
$warehouseId = $data['warehouse_id'] ?? null;
|
||||
|
||||
if (empty($warehouseId)) {
|
||||
$warehouseName = $data['warehouse'] ?? '販賣機倉庫';
|
||||
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
|
||||
$warehouseId = $warehouse->id;
|
||||
}
|
||||
|
||||
$totalAmount = 0;
|
||||
|
||||
// 3. 處理訂單明細
|
||||
$orderItemsData = [];
|
||||
foreach ($items as $itemData) {
|
||||
$product = $products->get($itemData['product_code']);
|
||||
|
||||
$qty = $itemData['qty'];
|
||||
$price = $itemData['price'];
|
||||
$lineTotal = $qty * $price;
|
||||
$totalAmount += $lineTotal;
|
||||
|
||||
$orderItemsData[] = [
|
||||
'sales_order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name,
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $lineTotal,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// 4. 扣除庫存(強制模式,允許負庫存)
|
||||
$this->inventoryService->decreaseStock(
|
||||
$product->id,
|
||||
$warehouseId,
|
||||
$qty,
|
||||
"Vending Order: " . $order->external_order_id,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Batch insert order items
|
||||
SalesOrderItem::insert($orderItemsData);
|
||||
|
||||
$order->update(['total_amount' => $totalAmount]);
|
||||
|
||||
return [
|
||||
'status' => 'created',
|
||||
'message' => 'Vending order synced and stock deducted successfully',
|
||||
'order_id' => $order->id,
|
||||
];
|
||||
});
|
||||
|
||||
return $result;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
app/Modules/Integration/Controllers/OrderSyncController.php
Normal file
60
app/Modules/Integration/Controllers/OrderSyncController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Integration\Requests\SyncOrderRequest;
|
||||
use App\Modules\Integration\Actions\SyncOrderAction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OrderSyncController extends Controller
|
||||
{
|
||||
protected $syncOrderAction;
|
||||
|
||||
public function __construct(SyncOrderAction $syncOrderAction)
|
||||
{
|
||||
$this->syncOrderAction = $syncOrderAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收並同步外部交易訂單
|
||||
*
|
||||
* @param SyncOrderRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(SyncOrderRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
// 所有驗證皆已透過 SyncOrderRequest 自動處理
|
||||
// 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動)
|
||||
$result = $this->syncOrderAction->execute($request->validated());
|
||||
|
||||
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||
|
||||
return response()->json([
|
||||
'message' => $result['message'],
|
||||
'order_id' => $result['order_id'] ?? null,
|
||||
], $statusCode);
|
||||
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
// 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時)
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 系統層級的錯誤
|
||||
Log::error('Order Sync Failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'payload' => $request->all()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sync failed: An unexpected error occurred.'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProductSyncController extends Controller
|
||||
{
|
||||
protected $productService;
|
||||
|
||||
public function __construct(ProductServiceInterface $productService)
|
||||
{
|
||||
$this->productService = $productService;
|
||||
}
|
||||
|
||||
public function upsert(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'external_pos_id' => 'required|string|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'unit' => 'nullable|string|max:100',
|
||||
'brand' => 'nullable|string|max:100',
|
||||
'specification' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'member_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'wholesale_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'updated_at' => 'nullable|date',
|
||||
]);
|
||||
|
||||
try {
|
||||
$product = $this->productService->upsertFromPos($request->all());
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Product synced successfully',
|
||||
'data' => [
|
||||
'id' => $product->id,
|
||||
'external_pos_id' => $product->external_pos_id,
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
||||
return response()->json([
|
||||
'message' => 'Sync failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SalesOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示銷售訂單列表
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = SalesOrder::query();
|
||||
|
||||
// 搜尋篩選 (外部訂單號)
|
||||
if ($request->filled('search')) {
|
||||
$query->where('external_order_id', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
// 來源篩選
|
||||
if ($request->filled('source')) {
|
||||
$query->where('source', $request->source);
|
||||
}
|
||||
|
||||
// 排序
|
||||
$query->orderBy('sold_at', 'desc');
|
||||
|
||||
$orders = $query->paginate($request->input('per_page', 10))
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('Integration/SalesOrders/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'per_page', 'source']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示單一銷售訂單詳情
|
||||
*/
|
||||
public function show(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->load(['items']);
|
||||
|
||||
return Inertia::render('Integration/SalesOrders/Show', [
|
||||
'order' => $salesOrder,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Integration\Requests\SyncVendingOrderRequest;
|
||||
use App\Modules\Integration\Actions\SyncVendingOrderAction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VendingOrderSyncController extends Controller
|
||||
{
|
||||
protected $syncVendingOrderAction;
|
||||
|
||||
public function __construct(SyncVendingOrderAction $syncVendingOrderAction)
|
||||
{
|
||||
$this->syncVendingOrderAction = $syncVendingOrderAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收並同步販賣機交易訂單
|
||||
*
|
||||
* @param SyncVendingOrderRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(SyncVendingOrderRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->syncVendingOrderAction->execute($request->validated());
|
||||
|
||||
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||
|
||||
return response()->json([
|
||||
'message' => $result['message'],
|
||||
'order_id' => $result['order_id'] ?? null,
|
||||
], $statusCode);
|
||||
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Vending Order Sync Failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'payload' => $request->all()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sync failed: An unexpected error occurred.'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Modules/Integration/IntegrationServiceProvider.php
Normal file
33
app/Modules/Integration/IntegrationServiceProvider.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Integration\Middleware\TenantIdentificationMiddleware;
|
||||
|
||||
class IntegrationServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
||||
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
||||
|
||||
// 註冊 Middleware 別名
|
||||
Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class);
|
||||
|
||||
// 定義 Integration API 速率限制(每分鐘 60 次,依 Token 使用者識別)
|
||||
RateLimiter::for('integration', function (Request $request) {
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantIdentificationMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 1. Check for X-Tenant-Domain header
|
||||
$domain = $request->header('X-Tenant-Domain');
|
||||
|
||||
if (! $domain) {
|
||||
return response()->json([
|
||||
'message' => 'Missing X-Tenant-Domain header.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 2. Find Tenant by domain
|
||||
// Assuming domains are stored in 'domains' table and linked to tenants
|
||||
// Or using Stancl's tenant finder.
|
||||
// Stancl Tenancy usually finds by domain automatically for web routes, but for API
|
||||
// we are doing manual identification because we might not be using subdomains for API calls (or maybe we are).
|
||||
// If the API endpoint is centrally hosted (e.g. api.star-erp.com/v1/...), we need this header.
|
||||
|
||||
// Let's try to initialize tenancy manually.
|
||||
// We need to find the tenant model that has this domain.
|
||||
try {
|
||||
$tenant = \App\Modules\Core\Models\Tenant::whereHas('domains', function ($query) use ($domain) {
|
||||
$query->where('domain', $domain);
|
||||
})->first();
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'message' => 'Tenant not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
Tenancy::initialize($tenant);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Tenant initialization failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
33
app/Modules/Integration/Models/SalesOrder.php
Normal file
33
app/Modules/Integration/Models/SalesOrder.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SalesOrder extends Model
|
||||
{
|
||||
protected $table = 'sales_orders';
|
||||
|
||||
protected $fillable = [
|
||||
'external_order_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'total_amount',
|
||||
'sold_at',
|
||||
'raw_payload',
|
||||
'source',
|
||||
'source_label',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sold_at' => 'datetime',
|
||||
'raw_payload' => 'array',
|
||||
'total_amount' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesOrderItem::class);
|
||||
}
|
||||
}
|
||||
31
app/Modules/Integration/Models/SalesOrderItem.php
Normal file
31
app/Modules/Integration/Models/SalesOrderItem.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SalesOrderItem extends Model
|
||||
{
|
||||
protected $table = 'sales_order_items';
|
||||
|
||||
protected $fillable = [
|
||||
'sales_order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'quantity',
|
||||
'price',
|
||||
'total',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:4',
|
||||
'price' => 'decimal:4',
|
||||
'total' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesOrder::class, 'sales_order_id');
|
||||
}
|
||||
}
|
||||
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SyncOrderRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'external_order_id' => 'required|string',
|
||||
'warehouse' => 'nullable|string',
|
||||
'warehouse_id' => 'nullable|integer',
|
||||
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
||||
'sold_at' => 'nullable|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.pos_product_id' => 'required|string',
|
||||
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||
'items.*.price' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SyncVendingOrderRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 販賣機訂單同步的驗證規則
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'external_order_id' => 'required|string',
|
||||
'machine_id' => 'nullable|string',
|
||||
'warehouse' => 'nullable|string',
|
||||
'warehouse_id' => 'nullable|integer',
|
||||
'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other',
|
||||
'sold_at' => 'nullable|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_code' => 'required|string', // 使用 ERP 商品代碼
|
||||
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||
'items.*.price' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
14
app/Modules/Integration/Routes/api.php
Normal file
14
app/Modules/Integration/Routes/api.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Integration\Controllers\ProductSyncController;
|
||||
use App\Modules\Integration\Controllers\OrderSyncController;
|
||||
use App\Modules\Integration\Controllers\VendingOrderSyncController;
|
||||
|
||||
Route::prefix('api/v1/integration')
|
||||
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
|
||||
->group(function () {
|
||||
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
||||
Route::post('orders', [OrderSyncController::class, 'store']);
|
||||
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
|
||||
});
|
||||
11
app/Modules/Integration/Routes/web.php
Normal file
11
app/Modules/Integration/Routes/web.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Modules\Integration\Controllers\SalesOrderController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['web', 'auth', 'verified'])->group(function () {
|
||||
Route::prefix('integration')->name('integration.')->group(function () {
|
||||
Route::get('sales-orders', [SalesOrderController::class, 'index'])->name('sales-orders.index');
|
||||
Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show');
|
||||
});
|
||||
});
|
||||
@@ -15,15 +15,15 @@ interface InventoryServiceInterface
|
||||
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
|
||||
|
||||
/**
|
||||
* Decrease stock for a product (e.g., when an order is placed).
|
||||
*
|
||||
* @param int $productId
|
||||
* @param int $warehouseId
|
||||
* @param float $quantity
|
||||
* @param string|null $reason
|
||||
* @param bool $force
|
||||
* @param string|null $slot
|
||||
* @return void
|
||||
*/
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void;
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void;
|
||||
|
||||
/**
|
||||
* Get all active warehouses.
|
||||
@@ -106,10 +106,37 @@ interface InventoryServiceInterface
|
||||
*/
|
||||
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
|
||||
|
||||
/**
|
||||
* Find a specific inventory record by warehouse, product and batch.
|
||||
*
|
||||
* @param int $warehouseId
|
||||
* @param int $productId
|
||||
* @param string|null $batchNumber
|
||||
* @return object|null
|
||||
*/
|
||||
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber);
|
||||
|
||||
/**
|
||||
* 取得即時庫存查詢資料(含統計卡片 + 分頁明細)。
|
||||
*
|
||||
* @param array $filters 篩選條件
|
||||
* @param int $perPage 每頁筆數
|
||||
* @return array
|
||||
*/
|
||||
public function getStockQueryData(array $filters = [], int $perPage = 10): array;
|
||||
|
||||
/**
|
||||
* Get statistics for the dashboard.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardStats(): array;
|
||||
|
||||
/**
|
||||
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
|
||||
*
|
||||
* @param string $warehouseName
|
||||
* @return object
|
||||
*/
|
||||
public function findOrCreateWarehouseByName(string $warehouseName);
|
||||
}
|
||||
41
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
41
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Contracts;
|
||||
|
||||
/**
|
||||
* 產品服務介面 — 供跨模組使用(如 Integration 模組)。
|
||||
*/
|
||||
interface ProductServiceInterface
|
||||
{
|
||||
/**
|
||||
* 透過外部 POS ID 進行產品新增或更新(Upsert)。
|
||||
*
|
||||
* @param array $data
|
||||
* @return object
|
||||
*/
|
||||
public function upsertFromPos(array $data);
|
||||
|
||||
/**
|
||||
* 透過外部 POS ID 查找產品。
|
||||
*
|
||||
* @param string $externalPosId
|
||||
* @return object|null
|
||||
*/
|
||||
public function findByExternalPosId(string $externalPosId);
|
||||
|
||||
/**
|
||||
* 透過多個外部 POS ID 查找產品。
|
||||
*
|
||||
* @param array $externalPosIds
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByExternalPosIds(array $externalPosIds);
|
||||
|
||||
/**
|
||||
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||
*
|
||||
* @param array $codes
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByCodes(array $codes);
|
||||
}
|
||||
242
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
242
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\AdjustService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class AdjustDocController extends Controller
|
||||
{
|
||||
protected $adjustService;
|
||||
|
||||
public function __construct(AdjustService $adjustService)
|
||||
{
|
||||
$this->adjustService = $adjustService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryAdjustDoc::query()
|
||||
->with(['createdBy', 'postedBy', 'warehouse']);
|
||||
|
||||
// 搜尋
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('doc_no', 'like', "%{$search}%")
|
||||
->orWhere('reason', 'like', "%{$search}%")
|
||||
->orWhere('remarks', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$docs = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($doc) {
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'status' => $doc->status,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'reason' => $doc->reason,
|
||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||
'posted_at' => $doc->posted_at ? $doc->posted_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'remarks' => $doc->remarks,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// 模式 1: 從盤點單建立
|
||||
if ($request->filled('count_doc_id')) {
|
||||
$countDoc = InventoryCountDoc::findOrFail($request->count_doc_id);
|
||||
if ($countDoc->status !== 'completed') {
|
||||
$errorMsg = $countDoc->status === 'no_adjust'
|
||||
? '此盤點單無庫存差異,無需建立盤調單'
|
||||
: '只有已完成盤點的單據可以建立盤調單';
|
||||
return redirect()->back()->with('error', $errorMsg);
|
||||
}
|
||||
|
||||
// 檢查是否已存在對應的盤調單 (避免重複建立)
|
||||
if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) {
|
||||
return redirect()->back()->with('error', '此盤點單已建立過盤調單');
|
||||
}
|
||||
|
||||
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
|
||||
|
||||
return redirect()->route('inventory.adjust.show', [$doc->id])
|
||||
->with('success', '已從盤點單生成盤調單');
|
||||
}
|
||||
|
||||
// 模式 2: 一般手動調整 (保留原始邏輯但更新訊息)
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required',
|
||||
'reason' => 'required|string',
|
||||
'remarks' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$doc = $this->adjustService->createDoc(
|
||||
$validated['warehouse_id'],
|
||||
$validated['reason'],
|
||||
$validated['remarks'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return redirect()->route('inventory.adjust.show', [$doc->id])
|
||||
->with('success', '已建立盤調單');
|
||||
}
|
||||
|
||||
/**
|
||||
* API: 獲取可盤調的已完成盤點單 (支援掃描單號)
|
||||
*/
|
||||
public function getPendingCounts(Request $request)
|
||||
{
|
||||
$query = InventoryCountDoc::where('status', 'completed')
|
||||
->whereNotExists(function ($query) {
|
||||
$query->select(DB::raw(1))
|
||||
->from('inventory_adjust_docs')
|
||||
->whereColumn('inventory_adjust_docs.count_doc_id', 'inventory_count_docs.id');
|
||||
});
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where('doc_no', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$counts = $query->limit(10)->get()->map(function($c) {
|
||||
return [
|
||||
'id' => (string)$c->id,
|
||||
'doc_no' => $c->doc_no,
|
||||
'warehouse_name' => $c->warehouse->name,
|
||||
'completed_at' => $c->completed_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($counts);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryAdjustDoc $doc)
|
||||
{
|
||||
$action = $request->input('action', 'update');
|
||||
|
||||
if ($action === 'post') {
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只有草稿狀態的單據可以過帳');
|
||||
}
|
||||
$this->adjustService->post($doc, auth()->id());
|
||||
return redirect()->back()->with('success', '單據已過帳');
|
||||
}
|
||||
|
||||
if ($action === 'void') {
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只有草稿狀態的單據可以作廢');
|
||||
}
|
||||
$this->adjustService->void($doc, auth()->id());
|
||||
return redirect()->back()->with('success', '單據已作廢');
|
||||
}
|
||||
|
||||
// 一般更新 (更新品項與基本資訊)
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只有草稿狀態的單據可以修改');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'reason' => 'required|string',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required',
|
||||
'items.*.adjust_qty' => 'required|numeric',
|
||||
]);
|
||||
|
||||
$doc->update([
|
||||
'reason' => $request->reason,
|
||||
'remarks' => $request->remarks,
|
||||
]);
|
||||
|
||||
$this->adjustService->updateItems($doc, $request->items);
|
||||
|
||||
return redirect()->back()->with('success', '單據已更新');
|
||||
}
|
||||
|
||||
public function show(InventoryAdjustDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
|
||||
|
||||
// Pre-fetch relevant Inventory information (mainly for expiry date)
|
||||
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
|
||||
->where('warehouse_id', $doc->warehouse_id)
|
||||
->whereIn('product_id', $doc->items->pluck('product_id'))
|
||||
->whereIn('batch_number', $doc->items->pluck('batch_number'))
|
||||
->get()
|
||||
->mapWithKeys(function ($inv) {
|
||||
return [$inv->product_id . '-' . $inv->batch_number => $inv];
|
||||
});
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_id' => (string) $doc->warehouse_id,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'status' => $doc->status,
|
||||
'reason' => $doc->reason,
|
||||
'remarks' => $doc->remarks,
|
||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
|
||||
'count_doc_no' => $doc->countDoc?->doc_no,
|
||||
'items' => $doc->items->map(function ($item) use ($inventoryMap) {
|
||||
$inv = $inventoryMap->get($item->product_id . '-' . $item->batch_number);
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_id' => (string) $item->product_id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'qty_before' => (float) $item->qty_before,
|
||||
'adjust_qty' => (float) $item->adjust_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Show', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(InventoryAdjustDoc $doc)
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
|
||||
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
return redirect()->route('inventory.adjust.index')
|
||||
->with('success', '盤調單已刪除');
|
||||
}
|
||||
}
|
||||
237
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
237
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\CountService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CountDocController extends Controller
|
||||
{
|
||||
protected $countService;
|
||||
|
||||
public function __construct(CountService $countService)
|
||||
{
|
||||
$this->countService = $countService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryCountDoc::query()
|
||||
->with(['createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('doc_no', 'like', "%{$search}%")
|
||||
->orWhere('remarks', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
}
|
||||
|
||||
$countQuery = function ($query) {
|
||||
$query->whereNotNull('counted_qty');
|
||||
};
|
||||
|
||||
$docs = $query->withCount(['items', 'items as counted_items_count' => $countQuery])
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($doc) {
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'status' => $doc->status,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-',
|
||||
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'remarks' => $doc->remarks,
|
||||
'total_items' => $doc->items_count,
|
||||
'counted_items' => $doc->counted_items_count,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Count/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'remarks' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$doc = $this->countService->createDoc(
|
||||
$validated['warehouse_id'],
|
||||
$validated['remarks'] ?? null,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 自動執行快照
|
||||
$this->countService->snapshot($doc, false);
|
||||
|
||||
return redirect()->route('inventory.count.show', [$doc->id])
|
||||
->with('success', '已建立盤點單並完成庫存快照');
|
||||
}
|
||||
|
||||
public function show(InventoryCountDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
// 預先抓取相關的 Inventory 資訊 (主要為了取得效期)
|
||||
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
|
||||
->where('warehouse_id', $doc->warehouse_id)
|
||||
->whereIn('product_id', $doc->items->pluck('product_id'))
|
||||
->whereIn('batch_number', $doc->items->pluck('batch_number'))
|
||||
->get()
|
||||
->mapWithKeys(function ($inv) {
|
||||
return [$inv->product_id . '-' . $inv->batch_number => $inv];
|
||||
});
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_id' => (string) $doc->warehouse_id,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'status' => $doc->status,
|
||||
'remarks' => $doc->remarks,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'items' => $doc->items->map(function ($item) use ($inventoryMap) {
|
||||
$key = $item->product_id . '-' . $item->batch_number;
|
||||
$inv = $inventoryMap->get($key);
|
||||
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, // 新增效期
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'system_qty' => (float) $item->system_qty,
|
||||
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
|
||||
'diff_qty' => (float) $item->diff_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Count/Show', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(InventoryCountDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d') : date('Y-m-d'), // Use date only
|
||||
'created_at' => $doc->created_at->format('Y-m-d'),
|
||||
'print_date' => date('Y-m-d'),
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'items' => $doc->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'specification' => $item->product->specification,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'quantity' => (float) ($item->counted_qty ?? $item->system_qty), // Default to system qty if counted is null, or just counted? User wants "Count Sheet" -> maybe blank if not counted?
|
||||
// Actually, if it's "Completed", we show counted. If it's "Pending", we usually show blank or system.
|
||||
// The 'Show' page logic suggests we show counted_qty.
|
||||
'counted_qty' => $item->counted_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Count/Print', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '此盤點單已完成,無法修改');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.id' => 'required|exists:inventory_count_items,id',
|
||||
'items.*.counted_qty' => 'nullable|numeric|min:0',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if (isset($validated['items'])) {
|
||||
$this->countService->updateCount($doc, $validated['items']);
|
||||
}
|
||||
|
||||
// 重新讀取以獲取最新狀態
|
||||
$doc->refresh();
|
||||
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點完成,單據已自動存檔並完成。');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '盤點資料已暫存');
|
||||
}
|
||||
|
||||
public function reopen(InventoryCountDoc $doc)
|
||||
{
|
||||
// 權限檢查 (通常僅允許有權限者執行,例如 inventory.adjust)
|
||||
// 注意:前端已經用 <Can> 保護按鈕,後端這裡最好也加上檢查
|
||||
if (!auth()->user()->can('inventory.adjust')) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!in_array($doc->status, ['completed', 'no_adjust'])) {
|
||||
return redirect()->back()->with('error', '僅能針對已完成或無需盤調的盤點單重新開啟盤點');
|
||||
}
|
||||
|
||||
// 執行取消核准邏輯
|
||||
$doc->update([
|
||||
'status' => 'counting', // 回復為盤點中
|
||||
'completed_at' => null, // 清除完成時間
|
||||
'completed_by' => null, // 清除完成者
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '已重新開啟盤點,單據回復為盤點中狀態');
|
||||
}
|
||||
|
||||
public function destroy(InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
|
||||
}
|
||||
|
||||
// Activity Log handled by Model Trait
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點單已刪除');
|
||||
}
|
||||
}
|
||||
248
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
248
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Services\GoodsReceiptService;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
protected $goodsReceiptService;
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(
|
||||
GoodsReceiptService $goodsReceiptService,
|
||||
InventoryService $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
) {
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = GoodsReceipt::query()
|
||||
->select(['id', 'code', 'type', 'warehouse_id', 'vendor_id', 'received_date', 'status', 'created_at'])
|
||||
->with(['warehouse'])
|
||||
->withSum('items', 'total_amount');
|
||||
|
||||
// 關鍵字搜尋(單號)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where('code', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->filled('status') && $request->input('status') !== 'all') {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
// 倉庫篩選
|
||||
if ($request->filled('warehouse_id') && $request->input('warehouse_id') !== 'all') {
|
||||
$query->where('warehouse_id', $request->input('warehouse_id'));
|
||||
}
|
||||
|
||||
// 日期範圍篩選
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('received_date', '>=', $request->input('date_start'));
|
||||
}
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('received_date', '<=', $request->input('date_end'));
|
||||
}
|
||||
|
||||
// 每頁筆數
|
||||
$perPage = $request->input('per_page', 10);
|
||||
|
||||
$receipts = $query->orderBy('created_at', 'desc')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
// Manual Hydration for Vendors (Cross-Module)
|
||||
$vendorIds = collect($receipts->items())->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendors = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
|
||||
|
||||
$receipts->getCollection()->transform(function ($receipt) use ($vendors) {
|
||||
$receipt->vendor = $vendors->get($receipt->vendor_id);
|
||||
return $receipt;
|
||||
});
|
||||
|
||||
// 取得倉庫列表用於篩選
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Index', [
|
||||
'receipts' => $receipts,
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'per_page']),
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$receipt = GoodsReceipt::with([
|
||||
'warehouse',
|
||||
'items.product.category',
|
||||
'items.product.baseUnit'
|
||||
])->findOrFail($id);
|
||||
|
||||
// Manual Hydration for Vendor (Cross-Module)
|
||||
if ($receipt->vendor_id) {
|
||||
$receipt->vendor = $this->procurementService->getVendorsByIds([$receipt->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 手動計算統計資訊 (如果 Model 沒有定義對應的 Attribute)
|
||||
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Show', [
|
||||
'receipt' => $receipt
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// 取得待進貨的採購單列表(用於標準採購類型選擇)
|
||||
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
|
||||
|
||||
// 提取所有產品 ID 以便跨模組水和資料
|
||||
$productIds = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// 處理採購單資料,計算剩餘可收貨數量
|
||||
$formattedPOs = $pendingPOs->map(function ($po) use ($products) {
|
||||
return [
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'vendor_id' => $po->vendor_id,
|
||||
'vendor_name' => $po->vendor?->name ?? '',
|
||||
'warehouse_id' => $po->warehouse_id,
|
||||
'order_date' => $po->order_date,
|
||||
'items' => $po->items->map(function ($item) use ($products) {
|
||||
$product = $products->get($item->product_id);
|
||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $product?->baseUnit?->name ?? '個',
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unit_price' => $item->unit_price,
|
||||
];
|
||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||
];
|
||||
})->filter(fn($po) => $po['items']->count() > 0)->values();
|
||||
|
||||
// 取得所有廠商列表(用於雜項入庫/其他類型選擇)
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Create', [
|
||||
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||
'pendingPurchaseOrders' => $formattedPOs,
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'type' => 'required|in:standard,miscellaneous,other',
|
||||
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
|
||||
// Vendor ID is required if standard, but optional/nullable for misc/other?
|
||||
// Stick to existing logic: if standard, we infer vendor from PO usually, or frontend sends it.
|
||||
// For now let's make vendor_id optional for misc/other or user must select one?
|
||||
// "雜項入庫" might not have a vendor. Let's make it nullable.
|
||||
'vendor_id' => 'nullable|integer',
|
||||
'received_date' => 'required|date',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|integer|exists:products,id',
|
||||
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
|
||||
'items.*.quantity_received' => 'required|numeric|min:0',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.expiry_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$this->goodsReceiptService->store($validated);
|
||||
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨單已建立');
|
||||
}
|
||||
|
||||
// API to search POs
|
||||
public function searchPOs(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$pos = $this->procurementService->searchPendingPurchaseOrders($search);
|
||||
|
||||
return response()->json($pos);
|
||||
}
|
||||
|
||||
// API to search Products for Manual Entry
|
||||
public function searchProducts(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$products = $this->inventoryService->getProductsByName($search);
|
||||
|
||||
// Format for frontend
|
||||
$mapped = $products->map(function($product) {
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
|
||||
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($mapped);
|
||||
}
|
||||
|
||||
// API to search Vendors
|
||||
public function searchVendors(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$vendors = $this->procurementService->searchVendors($search);
|
||||
|
||||
return response()->json($vendors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除進貨單
|
||||
*/
|
||||
public function destroy(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
// 只有有權限的人可以刪除
|
||||
if (!auth()->user()->can('goods_receipts.delete')) {
|
||||
return redirect()->back()->with('error', '您沒有權限刪除進貨單');
|
||||
}
|
||||
|
||||
// 簡單刪除邏輯:刪除進貨單(品項由資料庫級聯刪除或手動處理)
|
||||
// 注意:實務上可能需要處理已入庫的庫存回滾,但在這個簡易 ERP 中通常是行政刪除
|
||||
$goodsReceipt->delete();
|
||||
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\TurnoverService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class InventoryAnalysisController extends Controller
|
||||
{
|
||||
protected $turnoverService;
|
||||
|
||||
public function __construct(TurnoverService $turnoverService)
|
||||
{
|
||||
$this->turnoverService = $turnoverService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'warehouse_id', 'category_id', 'search', 'per_page', 'sort_by', 'sort_order', 'status'
|
||||
]);
|
||||
|
||||
$analysisData = $this->turnoverService->getAnalysisData($filters, $request->input('per_page', 10));
|
||||
$kpis = $this->turnoverService->getKPIs($filters);
|
||||
|
||||
return Inertia::render('Inventory/Analysis/Index', [
|
||||
'analysisData' => $analysisData,
|
||||
'kpis' => $kpis,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
'categories' => Category::select('id', 'name')->get(),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,12 @@ use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
use App\Modules\Inventory\Imports\InventoryImport;
|
||||
use App\Modules\Inventory\Exports\InventoryTemplateExport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
|
||||
@@ -48,12 +53,18 @@ class InventoryController extends Controller
|
||||
->pluck('safety_stock', 'product_id')
|
||||
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
||||
|
||||
// 3. 準備 inventories (批號分組)
|
||||
$items = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction'])
|
||||
->get();
|
||||
|
||||
$inventories = $items->groupBy('product_id')->map(function ($batchItems) use ($safetyStockMap) {
|
||||
// 判斷是否為販賣機並調整分組
|
||||
$isVending = $warehouse->type === 'vending';
|
||||
|
||||
$inventories = $items->groupBy(function ($item) use ($isVending) {
|
||||
return $isVending
|
||||
? $item->product_id . '-' . ($item->location ?? 'NO-SLOT')
|
||||
: $item->product_id;
|
||||
})->map(function ($batchItems) use ($safetyStockMap, $isVending) {
|
||||
$firstItem = $batchItems->first();
|
||||
$product = $firstItem->product;
|
||||
$totalQuantity = $batchItems->sum('quantity');
|
||||
@@ -93,9 +104,10 @@ class InventoryController extends Controller
|
||||
'safetyStock' => null, // 批號層級不再有安全庫存
|
||||
'status' => '正常',
|
||||
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
||||
'location' => $inv->location,
|
||||
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? substr($inv->lastIncomingTransaction->actual_time, 0, 10) : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? substr($inv->lastOutgoingTransaction->actual_time, 0, 10) : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
@@ -130,16 +142,18 @@ class InventoryController extends Controller
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
||||
->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'costPrice' => (float) $product->cost_price,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -160,10 +174,11 @@ class InventoryController extends Controller
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
|
||||
'items.*.batchMode' => 'required|in:existing,new',
|
||||
'items.*.batchMode' => 'required|in:existing,new,none',
|
||||
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
|
||||
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
|
||||
'items.*.expiryDate' => 'nullable|date',
|
||||
'items.*.location' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated, $warehouse) {
|
||||
@@ -185,6 +200,26 @@ class InventoryController extends Controller
|
||||
if (isset($item['unit_cost'])) {
|
||||
$inventory->unit_cost = $item['unit_cost'];
|
||||
}
|
||||
} elseif ($item['batchMode'] === 'none') {
|
||||
// 模式 C:不使用批號 (自動累加至 NO-BATCH)
|
||||
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
|
||||
[
|
||||
'product_id' => $item['productId'],
|
||||
'batch_number' => 'NO-BATCH'
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $item['unit_cost'] ?? 0,
|
||||
'total_value' => 0,
|
||||
'arrival_date' => $validated['inboundDate'],
|
||||
'expiry_date' => null,
|
||||
'origin_country' => 'TW',
|
||||
]
|
||||
);
|
||||
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
} else {
|
||||
// 模式 B:建立新批號
|
||||
$originCountry = $item['originCountry'] ?? 'TW';
|
||||
@@ -206,6 +241,7 @@ class InventoryController extends Controller
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $item['unit_cost'] ?? 0, // 新增
|
||||
'total_value' => 0, // 稍後計算
|
||||
'location' => $item['location'] ?? null,
|
||||
'arrival_date' => $validated['inboundDate'],
|
||||
'expiry_date' => $item['expiryDate'] ?? null,
|
||||
'origin_country' => $originCountry,
|
||||
@@ -259,7 +295,8 @@ class InventoryController extends Controller
|
||||
'originCountry' => $inventory->origin_country,
|
||||
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'unitCost' => (float) $inventory->unit_cost, // 新增
|
||||
'unitCost' => (float) $inventory->unit_cost,
|
||||
'location' => $inventory->location,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -323,7 +360,7 @@ class InventoryController extends Controller
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -482,7 +519,61 @@ class InventoryController extends Controller
|
||||
$productId = $request->query('productId');
|
||||
|
||||
if ($productId) {
|
||||
// ... (略) ...
|
||||
$product = Product::findOrFail($productId);
|
||||
// 取得該倉庫中該商品的所有批號 ID
|
||||
$inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
$transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
|
||||
->with('inventory') // 需要批號資訊
|
||||
->orderBy('actual_time', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
// 手動 Hydrate 使用者資料
|
||||
$userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
// 計算商品在該倉庫的總量(不分批號)
|
||||
$currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
$transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
|
||||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||
$balanceAfter = $currentRunningTotal;
|
||||
|
||||
// 為下一筆(較舊的)紀錄更新 Running Total
|
||||
$currentRunningTotal -= (float) $tx->quantity;
|
||||
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
|
||||
'slot' => $tx->inventory?->location, // 加入貨道資訊
|
||||
];
|
||||
});
|
||||
|
||||
// 重新計算目前的總量(用於 Header 顯示,確保一致性)
|
||||
$totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
return Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => null, // 跨批號查詢沒有單一 ID
|
||||
'productName' => $product->name,
|
||||
'productCode' => $product->code,
|
||||
'batchNumber' => '所有批號',
|
||||
'quantity' => (float) $totalQuantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
|
||||
if ($inventoryId) {
|
||||
@@ -496,7 +587,7 @@ class InventoryController extends Controller
|
||||
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
|
||||
$transactions = $inventory->transactions->map(function ($tx) use ($users, $inventory) {
|
||||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
@@ -506,7 +597,8 @@ class InventoryController extends Controller
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
'slot' => $inventory->location, // 加入貨道資訊
|
||||
];
|
||||
});
|
||||
|
||||
@@ -527,4 +619,35 @@ class InventoryController extends Controller
|
||||
|
||||
return redirect()->back()->with('error', '未提供查詢參數');
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯入入庫
|
||||
*/
|
||||
public function import(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|mimes:xlsx,xls,csv',
|
||||
'inboundDate' => 'required|date',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
Excel::import(
|
||||
new InventoryImport($warehouse, $request->inboundDate, $request->notes),
|
||||
$request->file('file')
|
||||
);
|
||||
|
||||
return back()->with('success', '庫存資料匯入成功');
|
||||
} catch (\Exception $e) {
|
||||
return back()->withErrors(['file' => '匯入過程中發生錯誤: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載匯入範本 (.xlsx)
|
||||
*/
|
||||
public function template()
|
||||
{
|
||||
return Excel::download(new InventoryTemplateExport, '庫存匯入範本.xlsx');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\InventoryReportService;
|
||||
use App\Modules\Inventory\Exports\InventoryReportExport;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
|
||||
class InventoryReportController extends Controller
|
||||
{
|
||||
protected $reportService;
|
||||
|
||||
public function __construct(InventoryReportService $reportService)
|
||||
{
|
||||
$this->reportService = $reportService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id', 'category_id', 'search', 'per_page',
|
||||
'sort_by', 'sort_order'
|
||||
]);
|
||||
|
||||
if (!isset($filters['date_from'])) {
|
||||
$filters['date_from'] = date('Y-m-d');
|
||||
}
|
||||
if (!isset($filters['date_to'])) {
|
||||
$filters['date_to'] = date('Y-m-d');
|
||||
}
|
||||
|
||||
$reportData = $this->reportService->getReportData($filters, $request->input('per_page', 10));
|
||||
$summary = $this->reportService->getSummary($filters);
|
||||
|
||||
return Inertia::render('Inventory/Report/Index', [
|
||||
'reportData' => $reportData,
|
||||
'summary' => $summary,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
'categories' => Category::select('id', 'name')->get(),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'period', 'date_from', 'date_to', 'warehouse_id', 'category_id', 'search'
|
||||
]);
|
||||
|
||||
return Excel::download(new InventoryReportExport($this->reportService, $filters), 'inventory_report_' . date('YmdHis') . '.xlsx');
|
||||
}
|
||||
|
||||
public function show(Request $request, $productId)
|
||||
{
|
||||
// 明細頁面自身使用的篩選條件
|
||||
$filters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id'
|
||||
]);
|
||||
|
||||
// 報表頁面的完整篩選狀態(用於返回時恢復)
|
||||
$reportFilters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id',
|
||||
'category_id', 'search', 'per_page'
|
||||
]);
|
||||
// 將傳入的 report_page 轉回 page 以便 Link 元件正確生成回報表頁的連結
|
||||
if ($request->has('report_page')) {
|
||||
$reportFilters['page'] = $request->input('report_page');
|
||||
}
|
||||
|
||||
// 取得商品資訊 (用於顯示標題,含基本單位)
|
||||
$product = \App\Modules\Inventory\Models\Product::with('baseUnit')->findOrFail($productId);
|
||||
|
||||
$transactions = $this->reportService->getProductDetails($productId, $filters, 20);
|
||||
|
||||
return Inertia::render('Inventory/Report/Show', [
|
||||
'product' => [
|
||||
'id' => $product->id,
|
||||
'code' => $product->code,
|
||||
'name' => $product->name,
|
||||
'unit_name' => $product->baseUnit?->name ?? '-',
|
||||
],
|
||||
'transactions' => $transactions,
|
||||
'filters' => $filters,
|
||||
'reportFilters' => $reportFilters,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ use App\Modules\Inventory\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Modules\Inventory\Exports\ProductTemplateExport;
|
||||
use App\Modules\Inventory\Imports\ProductImport;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
@@ -25,6 +28,7 @@ class ProductController extends Controller
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('brand', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
@@ -66,6 +70,7 @@ class ProductController extends Controller
|
||||
return (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'category' => $product->category ? (object) [
|
||||
@@ -90,6 +95,12 @@ class ProductController extends Controller
|
||||
'name' => $product->purchaseUnit->name,
|
||||
] : null,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'location' => $product->location,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
'price' => (float) $product->price,
|
||||
'member_price' => (float) $product->member_price,
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -103,39 +114,126 @@ class ProductController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示指定的資源。
|
||||
*/
|
||||
public function show(Product $product): Response
|
||||
{
|
||||
return Inertia::render('Product/Show', [
|
||||
'product' => (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'category' => $product->category ? (object) [
|
||||
'id' => $product->category->id,
|
||||
'name' => $product->category->name,
|
||||
] : null,
|
||||
'brand' => $product->brand,
|
||||
'specification' => $product->specification,
|
||||
'baseUnitId' => $product->base_unit_id,
|
||||
'baseUnit' => $product->baseUnit ? (object) [
|
||||
'id' => $product->baseUnit->id,
|
||||
'name' => $product->baseUnit->name,
|
||||
] : null,
|
||||
'largeUnitId' => $product->large_unit_id,
|
||||
'largeUnit' => $product->largeUnit ? (object) [
|
||||
'id' => $product->largeUnit->id,
|
||||
'name' => $product->largeUnit->name,
|
||||
] : null,
|
||||
'purchaseUnitId' => $product->purchase_unit_id,
|
||||
'purchaseUnit' => $product->purchaseUnit ? (object) [
|
||||
'id' => $product->purchaseUnit->id,
|
||||
'name' => $product->purchaseUnit->name,
|
||||
] : null,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'location' => $product->location,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
'price' => (float) $product->price,
|
||||
'member_price' => (float) $product->member_price,
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示建立表單。
|
||||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('Product/Create', [
|
||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:2|unique:products,code',
|
||||
'code' => 'nullable|unique:products,code',
|
||||
'barcode' => 'nullable|unique:products,barcode',
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
|
||||
'base_unit_id' => 'required|exists:units,id',
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
], [
|
||||
'code.required' => '商品代號為必填',
|
||||
'code.max' => '商品代號最多 2 碼',
|
||||
'code.unique' => '商品代號已存在',
|
||||
'name.required' => '商品名稱為必填',
|
||||
'category_id.required' => '請選擇分類',
|
||||
'category_id.exists' => '所選分類不存在',
|
||||
'base_unit_id.required' => '基本庫存單位為必填',
|
||||
'base_unit_id.exists' => '所選基本單位不存在',
|
||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||
'conversion_rate.numeric' => '換算率必須為數字',
|
||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||
'conversion_rate' => 'nullable|numeric|min:0',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'member_price' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
if (empty($validated['barcode'])) {
|
||||
$validated['barcode'] = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
$product = Product::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '商品已建立');
|
||||
return redirect()->route('products.index')->with('success', '商品已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯表單。
|
||||
*/
|
||||
public function edit(Product $product): Response
|
||||
{
|
||||
return Inertia::render('Product/Edit', [
|
||||
'product' => (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'brand' => $product->brand,
|
||||
'specification' => $product->specification,
|
||||
'baseUnitId' => $product->base_unit_id,
|
||||
'largeUnitId' => $product->large_unit_id,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'purchaseUnitId' => $product->purchase_unit_id,
|
||||
'location' => $product->location,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
'price' => (float) $product->price,
|
||||
'member_price' => (float) $product->member_price,
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
],
|
||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,32 +242,39 @@ class ProductController extends Controller
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
|
||||
'code' => 'nullable|unique:products,code,' . $product->id,
|
||||
'barcode' => 'nullable|unique:products,barcode,' . $product->id,
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
'base_unit_id' => 'required|exists:units,id',
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
], [
|
||||
'code.required' => '商品代號為必填',
|
||||
'code.max' => '商品代號最多 2 碼',
|
||||
'code.unique' => '商品代號已存在',
|
||||
'name.required' => '商品名稱為必填',
|
||||
'category_id.required' => '請選擇分類',
|
||||
'category_id.exists' => '所選分類不存在',
|
||||
'base_unit_id.required' => '基本庫存單位為必填',
|
||||
'base_unit_id.exists' => '所選基本單位不存在',
|
||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||
'conversion_rate.numeric' => '換算率必須為數字',
|
||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||
'conversion_rate' => 'nullable|numeric|min:0',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'member_price' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
if (empty($validated['barcode'])) {
|
||||
$validated['barcode'] = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
$product->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '商品已更新');
|
||||
if ($request->input('from') === 'show') {
|
||||
return redirect()->route('products.show', $product->id)->with('success', '商品已更新');
|
||||
}
|
||||
|
||||
return redirect()->route('products.index')->with('success', '商品已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,4 +286,71 @@ class ProductController extends Controller
|
||||
|
||||
return redirect()->back()->with('success', '商品已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載匯入範本
|
||||
*/
|
||||
public function template()
|
||||
{
|
||||
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯入商品
|
||||
*/
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls',
|
||||
]);
|
||||
|
||||
try {
|
||||
Excel::import(new ProductImport, $request->file('file'));
|
||||
return redirect()->back()->with('success', '商品匯入成功');
|
||||
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
|
||||
$failures = $e->failures();
|
||||
$messages = [];
|
||||
foreach ($failures as $failure) {
|
||||
$messages[] = '第 ' . $failure->row() . ' 行: ' . implode(', ', $failure->errors());
|
||||
}
|
||||
return redirect()->back()->withErrors(['file' => implode("\n", $messages)]);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成隨機 8 碼代號 (大寫英文+數字)
|
||||
*/
|
||||
private function generateRandomCode(): string
|
||||
{
|
||||
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$code = '';
|
||||
|
||||
do {
|
||||
$code = '';
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$code .= $characters[rand(0, strlen($characters) - 1)];
|
||||
}
|
||||
} while (Product::where('code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成隨機 13 碼條碼 (純數字)
|
||||
*/
|
||||
private function generateRandomBarcode(): string
|
||||
{
|
||||
$barcode = '';
|
||||
|
||||
do {
|
||||
$barcode = '';
|
||||
for ($i = 0; $i < 13; $i++) {
|
||||
$barcode .= rand(0, 9);
|
||||
}
|
||||
} while (Product::where('barcode', $barcode)->exists());
|
||||
|
||||
return $barcode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,51 @@ class SafetyStockController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
// 準備現有庫存列表 (用於庫存量對比)
|
||||
// 獲取現有庫存 (用於抓取「已在倉庫中」的商品)
|
||||
$inventoryProductIds = Inventory::where('warehouse_id', $warehouse->id)->pluck('product_id')->unique();
|
||||
|
||||
// 準備安全庫存設定列表 (從資料庫讀取)
|
||||
$existingSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->with(['product.category', 'product.baseUnit'])
|
||||
->get();
|
||||
|
||||
$existingProductIds = $existingSettings->pluck('product_id')->toArray();
|
||||
|
||||
// 找出:有庫存但是「還沒設定過安全庫存」的商品
|
||||
$missingProductIds = $inventoryProductIds->diff($existingProductIds);
|
||||
|
||||
$missingProducts = Product::whereIn('id', $missingProductIds)
|
||||
->with(['category', 'baseUnit'])
|
||||
->get();
|
||||
|
||||
// 合併:已設定的 + 有庫存未設定的 (預設值 0)
|
||||
$safetyStockSettings = $existingSettings->map(function ($setting) {
|
||||
return [
|
||||
'id' => (string) $setting->id,
|
||||
'warehouseId' => (string) $setting->warehouse_id,
|
||||
'productId' => (string) $setting->product_id,
|
||||
'productName' => $setting->product->name,
|
||||
'productType' => $setting->product->category ? $setting->product->category->name : '其他',
|
||||
'safetyStock' => (float) $setting->safety_stock,
|
||||
'unit' => $setting->product->baseUnit?->name ?? '個',
|
||||
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||
'isNew' => false, // 標記為舊有設定
|
||||
];
|
||||
})->concat($missingProducts->map(function ($product) use ($warehouse) {
|
||||
return [
|
||||
'id' => 'temp_' . $product->id, // 暫時 ID
|
||||
'warehouseId' => (string) $warehouse->id,
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'productType' => $product->category ? $product->category->name : '其他',
|
||||
'safetyStock' => 0, // 預設 0
|
||||
'unit' => $product->baseUnit?->name ?? '個',
|
||||
'updatedAt' => now()->toIso8601String(),
|
||||
'isNew' => true, // 標記為建議新增
|
||||
];
|
||||
}))->values();
|
||||
|
||||
// 原本的 inventories 映射 (供顯示對比)
|
||||
$inventories = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->select('product_id', DB::raw('SUM(quantity) as total_quantity'))
|
||||
->groupBy('product_id')
|
||||
@@ -43,23 +87,6 @@ class SafetyStockController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
// 準備安全庫存設定列表 (從新表格讀取)
|
||||
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->with(['product.category', 'product.baseUnit'])
|
||||
->get()
|
||||
->map(function ($setting) {
|
||||
return [
|
||||
'id' => (string) $setting->id,
|
||||
'warehouseId' => (string) $setting->warehouse_id,
|
||||
'productId' => (string) $setting->product_id,
|
||||
'productName' => $setting->product->name,
|
||||
'productType' => $setting->product->category ? $setting->product->category->name : '其他',
|
||||
'safetyStock' => (float) $setting->safety_stock,
|
||||
'unit' => $setting->product->baseUnit?->name ?? '個',
|
||||
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/SafetyStockSettings', [
|
||||
'warehouse' => $warehouse,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
|
||||
59
app/Modules/Inventory/Controllers/StockQueryController.php
Normal file
59
app/Modules/Inventory/Controllers/StockQueryController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class StockQueryController extends Controller
|
||||
{
|
||||
protected InventoryServiceInterface $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 即時庫存查詢頁面
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only(['warehouse_id', 'category_id', 'search', 'status', 'sort_by', 'sort_order', 'per_page']);
|
||||
$perPage = (int) ($filters['per_page'] ?? 10);
|
||||
|
||||
$result = $this->inventoryService->getStockQueryData($filters, $perPage);
|
||||
|
||||
return Inertia::render('Inventory/StockQuery/Index', [
|
||||
'filters' => $filters,
|
||||
'summary' => $result['summary'],
|
||||
'inventories' => [
|
||||
'data' => $result['data'],
|
||||
'total' => $result['pagination']['total'],
|
||||
'per_page' => $result['pagination']['per_page'],
|
||||
'current_page' => $result['pagination']['current_page'],
|
||||
'last_page' => $result['pagination']['last_page'],
|
||||
'links' => $result['pagination']['links'],
|
||||
],
|
||||
'warehouses' => Warehouse::select('id', 'name')->orderBy('name')->get(),
|
||||
'categories' => Category::select('id', 'name')->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel 匯出
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$filters = $request->only(['warehouse_id', 'category_id', 'search', 'status']);
|
||||
|
||||
return \Maatwebsite\Excel\Facades\Excel::download(
|
||||
new \App\Modules\Inventory\Exports\StockQueryExport($filters),
|
||||
'即時庫存查詢_' . now()->format('Ymd_His') . '.xlsx'
|
||||
);
|
||||
}
|
||||
}
|
||||
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\StoreRequisition;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Services\StoreRequisitionService;
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class StoreRequisitionController extends Controller
|
||||
{
|
||||
protected StoreRequisitionService $service;
|
||||
protected CoreServiceInterface $coreService;
|
||||
|
||||
public function __construct(
|
||||
StoreRequisitionService $service,
|
||||
CoreServiceInterface $coreService
|
||||
) {
|
||||
$this->service = $service;
|
||||
$this->coreService = $coreService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫貨單列表
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = StoreRequisition::query();
|
||||
|
||||
// 搜尋(單號)
|
||||
if ($request->search) {
|
||||
$query->where('doc_no', 'like', "%{$request->search}%");
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->status && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// 倉庫篩選
|
||||
if ($request->warehouse_id) {
|
||||
$query->where('store_warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
// 日期範圍
|
||||
if ($request->date_start) {
|
||||
$query->whereDate('created_at', '>=', $request->date_start);
|
||||
}
|
||||
if ($request->date_end) {
|
||||
$query->whereDate('created_at', '<=', $request->date_end);
|
||||
}
|
||||
|
||||
// 排序
|
||||
$sortField = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
$allowedSorts = ['id', 'doc_no', 'status', 'created_at', 'submitted_at'];
|
||||
if (in_array($sortField, $allowedSorts)) {
|
||||
$query->orderBy($sortField, $sortOrder);
|
||||
} else {
|
||||
$query->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$requisitions = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// 水和倉庫名稱與使用者名稱
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$warehouseMap = $warehouses->keyBy('id');
|
||||
|
||||
$userIds = $requisitions->getCollection()
|
||||
->pluck('created_by')
|
||||
->merge($requisitions->getCollection()->pluck('approved_by'))
|
||||
->filter()
|
||||
->unique()
|
||||
->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$requisitions->getCollection()->transform(function ($req) use ($warehouseMap, $users) {
|
||||
$req->store_warehouse_name = $warehouseMap->get($req->store_warehouse_id)?->name ?? '-';
|
||||
$req->supply_warehouse_name = $warehouseMap->get($req->supply_warehouse_id)?->name ?? '-';
|
||||
$req->creator_name = $users->get($req->created_by)?->name ?? '-';
|
||||
$req->approver_name = $users->get($req->approved_by)?->name ?? '-';
|
||||
return $req;
|
||||
});
|
||||
|
||||
return Inertia::render('StoreRequisition/Index', [
|
||||
'requisitions' => $requisitions,
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_by', 'sort_order', 'per_page']),
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增頁面
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$products = Product::select('id', 'name', 'code', 'base_unit_id')
|
||||
->with('baseUnit:id,name')
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
return Inertia::render('StoreRequisition/Create', [
|
||||
'warehouses' => $warehouses->map(fn($w) => [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'type' => $w->type?->value,
|
||||
]),
|
||||
'products' => $products->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'code' => $p->code,
|
||||
'unit_name' => $p->baseUnit?->name,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存叫貨單
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'store_warehouse_id' => 'required|exists:warehouses,id',
|
||||
'remark' => 'nullable|string|max:500',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.requested_qty' => 'required|numeric|min:0.01',
|
||||
'items.*.remark' => 'nullable|string|max:200',
|
||||
], [
|
||||
'items.required' => '至少需要一項商品',
|
||||
'items.min' => '至少需要一項商品',
|
||||
'items.*.requested_qty.min' => '需求數量必須大於 0',
|
||||
]);
|
||||
|
||||
$requisition = $this->service->create(
|
||||
$request->only(['store_warehouse_id', 'remark']),
|
||||
$request->items,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 如果需要直接提交
|
||||
if ($request->boolean('submit_immediately')) {
|
||||
$this->service->submit($requisition, auth()->id());
|
||||
return redirect()->route('store-requisitions.index')
|
||||
->with('success', '叫貨單已提交審核');
|
||||
}
|
||||
|
||||
return redirect()->route('store-requisitions.show', $requisition->id)
|
||||
->with('success', '叫貨單已儲存為草稿');
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫貨單詳情
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
|
||||
|
||||
// 水和倉庫
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$warehouseMap = $warehouses->keyBy('id');
|
||||
|
||||
$requisition->store_warehouse_name = $warehouseMap->get($requisition->store_warehouse_id)?->name ?? '-';
|
||||
$requisition->supply_warehouse_name = $warehouseMap->get($requisition->supply_warehouse_id)?->name ?? '-';
|
||||
|
||||
// 水和使用者
|
||||
$userIds = collect([$requisition->created_by, $requisition->approved_by])->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
$requisition->creator_name = $users->get($requisition->created_by)?->name ?? '-';
|
||||
$requisition->approver_name = $users->get($requisition->approved_by)?->name ?? '-';
|
||||
|
||||
// 水和明細商品資訊
|
||||
$requisition->items->transform(function ($item) {
|
||||
$item->product_name = $item->product?->name ?? '-';
|
||||
$item->product_code = $item->product?->code ?? '-';
|
||||
$item->unit_name = $item->product?->baseUnit?->name ?? '-';
|
||||
return $item;
|
||||
});
|
||||
|
||||
// 取得庫存資訊(顯示該商品在申請倉庫的現有庫存量)
|
||||
$productIds = $requisition->items->pluck('product_id')->toArray();
|
||||
$inventories = Inventory::where('warehouse_id', $requisition->store_warehouse_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->select('product_id')
|
||||
->selectRaw('SUM(quantity) as total_qty')
|
||||
->groupBy('product_id')
|
||||
->get()
|
||||
->keyBy('product_id');
|
||||
|
||||
$requisition->items->transform(function ($item) use ($inventories) {
|
||||
$item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0;
|
||||
return $item;
|
||||
});
|
||||
|
||||
// 操作紀錄
|
||||
$activities = \Spatie\Activitylog\Models\Activity::where('subject_type', StoreRequisition::class)
|
||||
->where('subject_id', $requisition->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return Inertia::render('StoreRequisition/Show', [
|
||||
'requisition' => $requisition,
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
'activities' => $activities,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 編輯頁面
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
|
||||
|
||||
if (!in_array($requisition->status, ['draft', 'rejected'])) {
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('error', '僅能編輯草稿或被駁回的叫貨單');
|
||||
}
|
||||
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$products = Product::select('id', 'name', 'code', 'base_unit_id')
|
||||
->with('baseUnit:id,name')
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
return Inertia::render('StoreRequisition/Create', [
|
||||
'requisition' => $requisition,
|
||||
'warehouses' => $warehouses->map(fn($w) => [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'type' => $w->type?->value,
|
||||
]),
|
||||
'products' => $products->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'code' => $p->code,
|
||||
'unit_name' => $p->baseUnit?->name,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新叫貨單
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'store_warehouse_id' => 'required|exists:warehouses,id',
|
||||
'remark' => 'nullable|string|max:500',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.requested_qty' => 'required|numeric|min:0.01',
|
||||
'items.*.remark' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$requisition = $this->service->update(
|
||||
$requisition,
|
||||
$request->only(['store_warehouse_id', 'remark']),
|
||||
$request->items
|
||||
);
|
||||
|
||||
// 如果需要直接提交
|
||||
if ($request->boolean('submit_immediately')) {
|
||||
$this->service->submit($requisition, auth()->id());
|
||||
return redirect()->route('store-requisitions.index')
|
||||
->with('success', '叫貨單已重新提交審核');
|
||||
}
|
||||
|
||||
return redirect()->route('store-requisitions.show', $requisition->id)
|
||||
->with('success', '叫貨單已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交審核
|
||||
*/
|
||||
public function submit($id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
$this->service->submit($requisition, auth()->id());
|
||||
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('success', '叫貨單已提交審核');
|
||||
}
|
||||
|
||||
/**
|
||||
* 核准叫貨單
|
||||
*/
|
||||
public function approve(Request $request, $id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'supply_warehouse_id' => 'required|exists:warehouses,id',
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:store_requisition_items,id',
|
||||
'items.*.approved_qty' => 'required|numeric|min:0',
|
||||
], [
|
||||
'supply_warehouse_id.required' => '請選擇供貨倉庫',
|
||||
]);
|
||||
|
||||
$this->service->approve($requisition, $request->only(['supply_warehouse_id', 'items']), auth()->id());
|
||||
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('success', '叫貨單已核准,調撥單已自動產生');
|
||||
}
|
||||
|
||||
/**
|
||||
* 駁回叫貨單
|
||||
*/
|
||||
public function reject(Request $request, $id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'reject_reason' => 'required|string|max:500',
|
||||
], [
|
||||
'reject_reason.required' => '請填寫駁回原因',
|
||||
]);
|
||||
|
||||
$this->service->reject($requisition, $request->reject_reason, auth()->id());
|
||||
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('success', '叫貨單已駁回');
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除叫貨單(僅限草稿)
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
if ($requisition->status !== 'draft') {
|
||||
return back()->withErrors(['error' => '僅能刪除草稿狀態的叫貨單']);
|
||||
}
|
||||
|
||||
$requisition->items()->delete();
|
||||
$requisition->delete();
|
||||
|
||||
return redirect()->route('store-requisitions.index')
|
||||
->with('success', '叫貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -3,135 +3,279 @@
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Enums\WarehouseType;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Services\TransferService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TransferOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 儲存撥補單(建立調撥單並執行庫存轉移)
|
||||
*/
|
||||
protected $transferService;
|
||||
|
||||
public function __construct(TransferService $transferService)
|
||||
{
|
||||
$this->transferService = $transferService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryTransferOrder::query()
|
||||
->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
|
||||
// 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('from_warehouse_id', $request->warehouse_id)
|
||||
->orWhere('to_warehouse_id', $request->warehouse_id);
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$orders = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($order) {
|
||||
return [
|
||||
'id' => (string) $order->id,
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'status' => $order->status,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $order->createdBy?->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Index', [
|
||||
'orders' => $orders,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// 兼容前端不同的參數命名 (from/source, to/target)
|
||||
$fromId = $request->input('from_warehouse_id') ?? $request->input('sourceWarehouseId');
|
||||
$toId = $request->input('to_warehouse_id') ?? $request->input('targetWarehouseId');
|
||||
|
||||
$validated = $request->validate([
|
||||
'sourceWarehouseId' => 'required|exists:warehouses,id',
|
||||
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
|
||||
'productId' => 'required|exists:products,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'transferDate' => 'required|date',
|
||||
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
|
||||
'from_warehouse_id' => 'required_without:sourceWarehouseId|exists:warehouses,id',
|
||||
'to_warehouse_id' => 'required_without:targetWarehouseId|exists:warehouses,id|different:from_warehouse_id',
|
||||
'transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
'remarks' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
|
||||
'instant_post' => 'boolean',
|
||||
// 支援單筆商品直接建立 (撥補單模式)
|
||||
'product_id' => 'nullable|exists:products,id',
|
||||
'quantity' => 'nullable|numeric|min:0.01',
|
||||
'batch_number' => 'nullable|string',
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated) {
|
||||
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||
->where('product_id', $validated['productId'])
|
||||
->where('batch_number', $validated['batchNumber'])
|
||||
->first();
|
||||
$remarks = $validated['remarks'] ?? $validated['notes'] ?? null;
|
||||
$transitWarehouseId = $validated['transit_warehouse_id'] ?? null;
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => ['來源倉庫指定批號庫存不足'],
|
||||
]);
|
||||
$order = $this->transferService->createOrder(
|
||||
$fromId,
|
||||
$toId,
|
||||
$remarks,
|
||||
auth()->id(),
|
||||
$transitWarehouseId
|
||||
);
|
||||
|
||||
if ($request->input('instant_post') === true) {
|
||||
try {
|
||||
$this->transferService->dispatch($order, auth()->id());
|
||||
|
||||
return redirect()->back()->with('success', '撥補成功,庫存已更新');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $validated['targetWarehouseId'],
|
||||
'product_id' => $validated['productId'],
|
||||
'batch_number' => $validated['batchNumber'],
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'total_value' => 0,
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
);
|
||||
return redirect()->route('inventory.transfer.show', [$order->id])
|
||||
->with('success', '已建立調撥單');
|
||||
}
|
||||
|
||||
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
|
||||
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
|
||||
public function show(InventoryTransferOrder $order)
|
||||
{
|
||||
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'transitWarehouse', 'createdBy', 'postedBy', 'dispatchedBy', 'receivedBy', 'storeRequisition']);
|
||||
|
||||
// 3. 執行庫存轉移 (扣除來源)
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $validated['quantity'];
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
|
||||
$sourceInventory->save();
|
||||
$orderData = [
|
||||
'id' => (string) $order->id,
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_id' => (string) $order->from_warehouse_id,
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'from_warehouse_default_transit' => $order->fromWarehouse->default_transit_warehouse_id ? (string)$order->fromWarehouse->default_transit_warehouse_id : null,
|
||||
'to_warehouse_id' => (string) $order->to_warehouse_id,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'to_warehouse_type' => $order->toWarehouse->type->value,
|
||||
// 在途倉資訊
|
||||
'transit_warehouse_id' => $order->transit_warehouse_id ? (string) $order->transit_warehouse_id : null,
|
||||
'transit_warehouse_name' => $order->transitWarehouse?->name,
|
||||
'transit_warehouse_plate' => $order->transitWarehouse?->license_plate,
|
||||
'transit_warehouse_driver' => $order->transitWarehouse?->driver_name,
|
||||
'status' => $order->status,
|
||||
'remarks' => $order->remarks,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $order->createdBy?->name,
|
||||
'posted_at' => $order->posted_at?->format('Y-m-d H:i'),
|
||||
'posted_by' => $order->postedBy?->name,
|
||||
'dispatched_at' => $order->dispatched_at?->format('Y-m-d H:i'),
|
||||
'dispatched_by' => $order->dispatchedBy?->name,
|
||||
'received_at' => $order->received_at?->format('Y-m-d H:i'),
|
||||
'received_by' => $order->receivedBy?->name,
|
||||
'requisition' => $order->storeRequisition ? [
|
||||
'id' => (string) $order->storeRequisition->id,
|
||||
'doc_no' => $order->storeRequisition->doc_no,
|
||||
] : null,
|
||||
'items' => $order->items->map(function ($item) use ($order) {
|
||||
$stock = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
// 記錄來源異動
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '撥補出庫',
|
||||
'quantity' => -$validated['quantity'],
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_id' => (string) $item->product_id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'quantity' => (float) $item->quantity,
|
||||
'position' => $item->position,
|
||||
'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0),
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
// 取得在途倉庫列表供前端選擇
|
||||
$transitWarehouses = Warehouse::where('type', WarehouseType::TRANSIT)
|
||||
->get()
|
||||
->map(fn($w) => [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
'license_plate' => $w->license_plate,
|
||||
'driver_name' => $w->driver_name,
|
||||
]);
|
||||
|
||||
// 4. 執行庫存轉移 (增加目標)
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $validated['quantity'];
|
||||
return Inertia::render('Inventory/Transfer/Show', [
|
||||
'order' => $orderData,
|
||||
'transitWarehouses' => $transitWarehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
|
||||
// 確保目標庫存也有成本 (如果是繼承來的)
|
||||
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
public function update(Request $request, InventoryTransferOrder $order)
|
||||
{
|
||||
// 收貨動作:僅限 dispatched 狀態
|
||||
if ($request->input('action') === 'receive') {
|
||||
if ($order->status !== 'dispatched') {
|
||||
return redirect()->back()->with('error', '僅能對已出貨的調撥單進行收貨確認');
|
||||
}
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
|
||||
$targetInventory->save();
|
||||
try {
|
||||
$this->transferService->receive($order, auth()->id());
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已收貨完成');
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->back()->withErrors($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// 記錄目標異動
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '撥補入庫',
|
||||
'quantity' => $validated['quantity'],
|
||||
'unit_cost' => $targetInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
// 以下操作僅限草稿
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
// 1. 更新在途倉庫(如果前端有傳)
|
||||
if ($request->has('transit_warehouse_id')) {
|
||||
$order->transit_warehouse_id = $request->input('transit_warehouse_id') ?: null;
|
||||
}
|
||||
|
||||
// 2. 先更新資料 (如果請求中包含 items,則先執行儲存)
|
||||
$itemsChanged = false;
|
||||
if ($request->has('items')) {
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.position' => 'nullable|string',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
$itemsChanged = $this->transferService->updateItems($order, $validated['items']);
|
||||
}
|
||||
|
||||
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
|
||||
$remarksChanged = false;
|
||||
if ($request->has('remarks')) {
|
||||
$remarksChanged = $order->remarks !== $request->input('remarks');
|
||||
$order->remarks = $request->input('remarks');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
|
||||
});
|
||||
if ($itemsChanged || $remarksChanged || $order->isDirty()) {
|
||||
$order->touch();
|
||||
$message = '儲存成功';
|
||||
} else {
|
||||
$message = '資料未變更';
|
||||
}
|
||||
|
||||
// 3. 判斷是否需要出貨/過帳
|
||||
if ($request->input('action') === 'post') {
|
||||
try {
|
||||
$this->transferService->dispatch($order, auth()->id());
|
||||
$hasTransit = !empty($order->transit_warehouse_id);
|
||||
$successMsg = $hasTransit ? '調撥單已出貨,庫存已轉入在途倉' : '調撥單已過帳完成';
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', $successMsg);
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->back()->withErrors($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function destroy(InventoryTransferOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取特定倉庫的庫存列表 (API)
|
||||
* 獲取特定倉庫的庫存列表 (API) - 保留給前端選擇商品用
|
||||
*/
|
||||
public function getWarehouseInventories(Warehouse $warehouse)
|
||||
{
|
||||
$inventories = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'product.category'])
|
||||
->where('quantity', '>', 0) // 只回傳有庫存的
|
||||
->where('quantity', '>', 0)
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'product_id' => (string) $inv->product_id,
|
||||
'product_name' => $inv->product->name,
|
||||
'product_code' => $inv->product->code,
|
||||
'product_barcode' => $inv->product->barcode,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'unit_cost' => (float) $inv->unit_cost, // 新增
|
||||
'total_value' => (float) $inv->total_value, // 新增
|
||||
'unit_cost' => (float) $inv->unit_cost,
|
||||
'unit_name' => $inv->product->baseUnit?->name ?? '個',
|
||||
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
];
|
||||
@@ -139,4 +283,30 @@ class TransferOrderController extends Controller
|
||||
|
||||
return response()->json($inventories);
|
||||
}
|
||||
public function importItems(Request $request, InventoryTransferOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能在草稿狀態下匯入明細');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls,csv',
|
||||
]);
|
||||
|
||||
try {
|
||||
\Maatwebsite\Excel\Facades\Excel::import(new \App\Modules\Inventory\Imports\InventoryTransferItemImport($order), $request->file('file'));
|
||||
return redirect()->back()->with('success', '匯入成功');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', '匯入失敗:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function template()
|
||||
{
|
||||
return \Maatwebsite\Excel\Facades\Excel::download(
|
||||
new \App\Modules\Inventory\Exports\InventoryTransferTemplateExport(),
|
||||
'調撥單明細匯入範本.xlsx'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,69 +24,128 @@ class WarehouseController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
}
|
||||
|
||||
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
|
||||
->withSum('inventories as book_amount', 'total_value') // 帳面金額
|
||||
->withSum(['inventories as available_stock' => function ($query) {
|
||||
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期)
|
||||
// 可用庫存條件
|
||||
$query->where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
});
|
||||
}], 'quantity')
|
||||
->withSum(['inventories as available_amount' => function ($query) {
|
||||
// 可用金額條件 (與可用庫存一致)
|
||||
$query->where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
});
|
||||
}], 'total_value')
|
||||
->withSum(['inventories as abnormal_amount' => function ($query) {
|
||||
$query->where('quantity', '>', 0)
|
||||
->where(function ($q) {
|
||||
$q->where('quality_status', '!=', 'normal')
|
||||
->orWhere(function ($sq) {
|
||||
$sq->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '<', now());
|
||||
})
|
||||
->orWhereHas('warehouse', function ($wq) {
|
||||
$wq->where('type', \App\Enums\WarehouseType::QUARANTINE);
|
||||
});
|
||||
});
|
||||
}], 'total_value')
|
||||
->addSelect(['low_stock_count' => function ($query) {
|
||||
$query->selectRaw('count(*)')
|
||||
->from('warehouse_product_safety_stocks as ss')
|
||||
->whereColumn('ss.warehouse_id', 'warehouses.id')
|
||||
->whereRaw('(SELECT COALESCE(SUM(quantity), 0) FROM inventories WHERE warehouse_id = ss.warehouse_id AND product_id = ss.product_id) < ss.safety_stock');
|
||||
}])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
// 修正各倉庫列表中的可用庫存計算:若倉庫不可銷售,則可用庫存為 0
|
||||
$warehouses->getCollection()->transform(function ($w) {
|
||||
if (!$w->is_sellable) {
|
||||
$w->available_stock = 0;
|
||||
}
|
||||
return $w;
|
||||
});
|
||||
|
||||
// 計算全域總計 (不分頁)
|
||||
$totals = [
|
||||
'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('is_sellable', true);
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
})->sum('quantity'),
|
||||
'available_amount' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
})->sum('total_value'),
|
||||
'abnormal_amount' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
|
||||
->where(function ($q) {
|
||||
$q->where('quality_status', '!=', 'normal')
|
||||
->orWhere(function ($sq) {
|
||||
$sq->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '<', now());
|
||||
})
|
||||
->orWhereHas('warehouse', function ($wq) {
|
||||
$wq->where('type', \App\Enums\WarehouseType::QUARANTINE);
|
||||
});
|
||||
})->sum('total_value'),
|
||||
'book_stock' => \App\Modules\Inventory\Models\Inventory::sum('quantity'),
|
||||
'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
|
||||
];
|
||||
|
||||
// 取得在途倉列表供前端選擇「預設在途倉」
|
||||
$transitWarehouses = Warehouse::where('type', \App\Enums\WarehouseType::TRANSIT)
|
||||
->select('id', 'name', 'license_plate', 'driver_name')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($w) => [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
'license_plate' => $w->license_plate,
|
||||
'driver_name' => $w->driver_name,
|
||||
]);
|
||||
|
||||
return Inertia::render('Warehouse/Index', [
|
||||
'warehouses' => $warehouses,
|
||||
'totals' => $totals,
|
||||
'filters' => $request->only(['search']),
|
||||
'transitWarehouses' => $transitWarehouses,
|
||||
'filters' => $request->only(['search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:20|unique:warehouses,code',
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
// 自動產生代碼
|
||||
$prefix = 'WH';
|
||||
$lastWarehouse = Warehouse::latest('id')->first();
|
||||
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
||||
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
$validated['code'] = $code;
|
||||
|
||||
Warehouse::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '倉庫已建立');
|
||||
@@ -95,13 +154,14 @@ class WarehouseController extends Controller
|
||||
public function update(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:20|unique:warehouses,code,' . $warehouse->id,
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$warehouse->update($validated);
|
||||
@@ -111,8 +171,9 @@ class WarehouseController extends Controller
|
||||
|
||||
public function destroy(Warehouse $warehouse)
|
||||
{
|
||||
// 檢查是否有相關聯的採購單
|
||||
if ($warehouse->purchaseOrders()->exists()) {
|
||||
// 檢查是否有相關聯的採購單 (跨模組檢查,不使用模型關聯以符合解耦規範)
|
||||
$hasPurchaseOrders = \App\Modules\Procurement\Models\PurchaseOrder::where('warehouse_id', $warehouse->id)->exists();
|
||||
if ($hasPurchaseOrders) {
|
||||
return redirect()->back()->with('error', '無法刪除:該倉庫有相關聯的採購單,請先處理採購單。');
|
||||
}
|
||||
|
||||
|
||||
54
app/Modules/Inventory/Exports/InstructionSheet.php
Normal file
54
app/Modules/Inventory/Exports/InstructionSheet.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class InstructionSheet implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||
{
|
||||
public function title(): string
|
||||
{
|
||||
return '填寫說明';
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'欄位名稱',
|
||||
'是否必填',
|
||||
'填寫說明',
|
||||
];
|
||||
}
|
||||
|
||||
public function collection()
|
||||
{
|
||||
return collect([
|
||||
['商品代號', '選填', '2-8 碼,若未填寫系統將自動生成。若代號已存在,將更新該商品資料。'],
|
||||
['條碼', '選填', '13 碼數字,若未填寫系統將自動生成。若條碼已存在(優先比對),將更新該商品資料。'],
|
||||
['商品名稱', '必填', '請填寫完整商品名稱。'],
|
||||
['類別名稱', '必填', '必須為系統中已存在的類別名稱(如:飲品)。'],
|
||||
['品牌', '選填', '商品品牌名稱。'],
|
||||
['規格', '選填', '商品規格描述(如:25kg/袋)。'],
|
||||
['基本單位', '必填', '必須為系統中已存在的單位名稱(如:瓶、個)。'],
|
||||
['大單位', '選填', '若有大單位換算請填寫(如:箱)。'],
|
||||
['換算率', '若有大單位則必填', '1 個大單位等於多少個基本單位。'],
|
||||
['成本價', '選填', '數字,預設為 0。'],
|
||||
['售價', '選填', '數字,預設為 0。'],
|
||||
['會員價', '選填', '數字,預設為 0。'],
|
||||
['批發價', '選填', '數字,預設為 0。'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
// 第一行標題粗體
|
||||
1 => ['font' => ['bold' => true]],
|
||||
// 欄位寬度自動
|
||||
];
|
||||
}
|
||||
}
|
||||
65
app/Modules/Inventory/Exports/InventoryReportExport.php
Normal file
65
app/Modules/Inventory/Exports/InventoryReportExport.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use App\Modules\Inventory\Services\InventoryReportService;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class InventoryReportExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles
|
||||
{
|
||||
protected $service;
|
||||
protected $filters;
|
||||
|
||||
public function __construct(InventoryReportService $service, array $filters)
|
||||
{
|
||||
$this->service = $service;
|
||||
$this->filters = $filters;
|
||||
}
|
||||
|
||||
public function collection()
|
||||
{
|
||||
return $this->service->getReportData($this->filters, null); // perPage = null to get all
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'商品代碼',
|
||||
'商品名稱',
|
||||
'分類',
|
||||
'進貨量',
|
||||
'出貨量',
|
||||
'調撥入',
|
||||
'調撥出',
|
||||
'調整量',
|
||||
'淨變動',
|
||||
];
|
||||
}
|
||||
|
||||
public function map($row): array
|
||||
{
|
||||
return [
|
||||
$row->product_code,
|
||||
$row->product_name,
|
||||
$row->category_name ?? '-',
|
||||
$row->inbound_qty,
|
||||
$row->outbound_qty,
|
||||
$row->transfer_in_qty,
|
||||
$row->transfer_out_qty,
|
||||
$row->adjust_qty,
|
||||
$row->net_change,
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Modules/Inventory/Exports/InventoryTemplateExport.php
Normal file
87
app/Modules/Inventory/Exports/InventoryTemplateExport.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
|
||||
class InventoryTemplateExport implements WithMultipleSheets
|
||||
{
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
new InventoryDataSheet(),
|
||||
new InventoryInstructionSheet(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class InventoryDataSheet implements FromArray, WithHeadings, WithTitle, ShouldAutoSize
|
||||
{
|
||||
public function array(): array
|
||||
{
|
||||
// 資料分頁保持完全空白
|
||||
return [];
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'商品條碼',
|
||||
'商品代號',
|
||||
'商品名稱',
|
||||
'數量',
|
||||
'入庫單價',
|
||||
'儲位/貨道',
|
||||
'批號',
|
||||
'產地',
|
||||
'效期',
|
||||
];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '資料填寫';
|
||||
}
|
||||
}
|
||||
|
||||
class InventoryInstructionSheet implements FromArray, WithHeadings, WithTitle, ShouldAutoSize
|
||||
{
|
||||
public function array(): array
|
||||
{
|
||||
return [
|
||||
['商品條碼', '擇一輸入', '系統會「優先」依據條碼匹配商品。若有填寫,條碼必須存在於系統中'],
|
||||
['商品代號', '擇一輸入', '若條碼未填寫,系統會依據代號匹配商品'],
|
||||
['商品名稱', '選填', '僅供對照參考,匯入時系統會自動忽略此欄位內容'],
|
||||
['數量', '必填', '入庫的商品數量,須為大於 0 的數字'],
|
||||
['入庫單價', '選填', '未填寫時將預設使用商品的「採購成本價」'],
|
||||
['儲位/貨道', '選填', '一般倉庫請填寫「儲位(位址)」,販賣機倉庫請填寫「貨道編號」(如: A1)'],
|
||||
['批號', '選填', '如需批次控管請填寫,若留空系統會自動標記為 "NO-BATCH"'],
|
||||
['產地', '選填', '商品的生產地資訊 (如:TW)'],
|
||||
['效期', '選填', '格式請務必使用 YYYY-MM-DD (例如: 2026-12-31)'],
|
||||
['', '', ''],
|
||||
['倉庫類型參考', '', '系統支援以下倉庫性質:'],
|
||||
['標準倉', '', '一般總倉、儲備倉'],
|
||||
['生產倉', '', '加工廠、中央廚房、原材料存放處'],
|
||||
['門市倉', '', '前台通路、店舖銷售現場'],
|
||||
['販賣機', '', 'IoT 自動販賣機設備,建議搭配「貨道」填寫'],
|
||||
['', '', ''],
|
||||
['匹配與匯入規則', '', '1. 系統會優先比對「商品條碼」,其次為「商品代號」。'],
|
||||
['', '', '2. 庫存將匯入至您在匯入前於系統介面所選擇的目標倉庫。'],
|
||||
['', '', '3. 若需區分不同貨道或批次,請分行填寫。'],
|
||||
];
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['欄位名稱', '必要性', '填寫說明'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '填寫規則';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\Exportable;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class InventoryTransferTemplateExport implements WithMultipleSheets
|
||||
{
|
||||
use Exportable;
|
||||
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
new class implements FromCollection, WithHeadings, WithTitle, WithStyles {
|
||||
public function collection()
|
||||
{
|
||||
return collect([
|
||||
['P001', 'BATCH-2024001', '10', 'A1', '範例:請刪除此列後填寫'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['商品代碼', '批號', '數量', '貨道/儲位', '備註'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '明細匯入';
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
},
|
||||
new class implements FromCollection, WithHeadings, WithTitle, WithStyles {
|
||||
public function collection()
|
||||
{
|
||||
return collect([
|
||||
['商品代碼', '必填', '請填寫系統中已存在的商品代號'],
|
||||
['數量', '必填', '必須為大於 0 的數字'],
|
||||
['批號', '選填', '若不填寫將自動對應「NO-BATCH」庫存'],
|
||||
['貨道/儲位', '選填', '主要用於目的倉庫為「販賣機」時指定貨道'],
|
||||
['備註', '選填', '可填寫該筆明細的備註說明'],
|
||||
['', '', ''],
|
||||
['提示', '附加模式', '匯入的明細將附加至現有單據,不會覆蓋原有資料'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['欄位名稱', '必要性', '說明'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '匯入規則說明';
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
$sheet->getColumnDimension('A')->setWidth(15);
|
||||
$sheet->getColumnDimension('B')->setWidth(15);
|
||||
$sheet->getColumnDimension('C')->setWidth(50);
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Modules/Inventory/Exports/ProductImportSheet.php
Normal file
43
app/Modules/Inventory/Exports/ProductImportSheet.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class ProductImportSheet implements WithHeadings, WithColumnFormatting, WithTitle
|
||||
{
|
||||
public function title(): string
|
||||
{
|
||||
return '商品匯入';
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'商品代號(選填)',
|
||||
'條碼(選填)',
|
||||
'商品名稱',
|
||||
'類別名稱',
|
||||
'品牌',
|
||||
'規格',
|
||||
'基本單位',
|
||||
'大單位',
|
||||
'換算率',
|
||||
'成本價',
|
||||
'售價',
|
||||
'會員價',
|
||||
'批發價',
|
||||
];
|
||||
}
|
||||
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return [
|
||||
'A' => NumberFormat::FORMAT_TEXT, // 商品代號
|
||||
'B' => NumberFormat::FORMAT_TEXT, // 條碼
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Modules/Inventory/Exports/ProductTemplateExport.php
Normal file
16
app/Modules/Inventory/Exports/ProductTemplateExport.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
|
||||
class ProductTemplateExport implements WithMultipleSheets
|
||||
{
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
new ProductImportSheet(),
|
||||
new InstructionSheet(),
|
||||
];
|
||||
}
|
||||
}
|
||||
167
app/Modules/Inventory/Exports/StockQueryExport.php
Normal file
167
app/Modules/Inventory/Exports/StockQueryExport.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class StockQueryExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles
|
||||
{
|
||||
protected array $filters;
|
||||
|
||||
public function __construct(array $filters = [])
|
||||
{
|
||||
$this->filters = $filters;
|
||||
}
|
||||
|
||||
public function collection()
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
$query = Inventory::query()
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('inventories.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('inventories.deleted_at')
|
||||
->select([
|
||||
'inventories.id',
|
||||
'inventories.quantity',
|
||||
'inventories.batch_number',
|
||||
'inventories.expiry_date',
|
||||
'inventories.quality_status',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'categories.name as category_name',
|
||||
'warehouses.name as warehouse_name',
|
||||
'ss.safety_stock',
|
||||
]);
|
||||
|
||||
// 篩選
|
||||
if (!empty($this->filters['warehouse_id'])) {
|
||||
$query->where('inventories.warehouse_id', $this->filters['warehouse_id']);
|
||||
}
|
||||
if (!empty($this->filters['category_id'])) {
|
||||
$query->where('products.category_id', $this->filters['category_id']);
|
||||
}
|
||||
if (!empty($this->filters['search'])) {
|
||||
$search = $this->filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.code', 'like', "%{$search}%")
|
||||
->orWhere('products.name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
if (!empty($this->filters['status'])) {
|
||||
switch ($this->filters['status']) {
|
||||
case 'low_stock':
|
||||
$query->whereNotNull('ss.safety_stock')
|
||||
->whereRaw('inventories.quantity <= ss.safety_stock')
|
||||
->where('inventories.quantity', '>=', 0);
|
||||
break;
|
||||
case 'negative':
|
||||
$query->where('inventories.quantity', '<', 0);
|
||||
break;
|
||||
case 'expiring':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '>', $today)
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
break;
|
||||
case 'expired':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $today);
|
||||
break;
|
||||
case 'abnormal':
|
||||
$query->where(function ($q) use ($today, $expiryThreshold) {
|
||||
$q->where('inventories.quantity', '<', 0)
|
||||
->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('ss.safety_stock')
|
||||
->whereRaw('inventories.quantity <= ss.safety_stock');
|
||||
})
|
||||
->orWhere(function ($q2) use ($expiryThreshold) {
|
||||
$q2->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->orderBy('products.code', 'asc')->get();
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'商品代碼',
|
||||
'商品名稱',
|
||||
'分類',
|
||||
'倉庫',
|
||||
'批號',
|
||||
'數量',
|
||||
'安全庫存',
|
||||
'到期日',
|
||||
'品質狀態',
|
||||
'狀態',
|
||||
];
|
||||
}
|
||||
|
||||
public function map($row): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
$statuses = [];
|
||||
if ($row->quantity < 0) {
|
||||
$statuses[] = '負庫存';
|
||||
}
|
||||
if ($row->safety_stock !== null && $row->quantity <= $row->safety_stock && $row->quantity >= 0) {
|
||||
$statuses[] = '低庫存';
|
||||
}
|
||||
if ($row->expiry_date) {
|
||||
if ($row->expiry_date <= $today) {
|
||||
$statuses[] = '已過期';
|
||||
} elseif ($row->expiry_date <= $expiryThreshold) {
|
||||
$statuses[] = '即將過期';
|
||||
}
|
||||
}
|
||||
if (empty($statuses)) {
|
||||
$statuses[] = '正常';
|
||||
}
|
||||
|
||||
$qualityLabels = [
|
||||
'normal' => '正常',
|
||||
'inspecting' => '檢驗中',
|
||||
'rejected' => '不合格',
|
||||
];
|
||||
|
||||
return [
|
||||
$row->product_code,
|
||||
$row->product_name,
|
||||
$row->category_name ?? '-',
|
||||
$row->warehouse_name,
|
||||
$row->batch_number ?? '-',
|
||||
$row->quantity,
|
||||
$row->safety_stock ?? '-',
|
||||
$row->expiry_date ?? '-',
|
||||
$qualityLabels[$row->quality_status] ?? $row->quality_status ?? '-',
|
||||
implode('、', $statuses),
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Modules/Inventory/Imports/InventoryImport.php
Normal file
137
app/Modules/Inventory/Imports/InventoryImport.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Imports;
|
||||
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMapping, SkipsEmptyRows
|
||||
{
|
||||
private $warehouse;
|
||||
private $inboundDate;
|
||||
private $notes;
|
||||
|
||||
public function __construct(Warehouse $warehouse, string $inboundDate, ?string $notes = null)
|
||||
{
|
||||
HeadingRowFormatter::default('none');
|
||||
$this->warehouse = $warehouse;
|
||||
$this->inboundDate = $inboundDate;
|
||||
$this->notes = $notes;
|
||||
}
|
||||
|
||||
public function map($row): array
|
||||
{
|
||||
// 處理條碼或代號為字串
|
||||
if (isset($row['商品條碼'])) {
|
||||
$row['商品條碼'] = (string) $row['商品條碼'];
|
||||
}
|
||||
if (isset($row['商品代號'])) {
|
||||
$row['商品代號'] = (string) $row['商品代號'];
|
||||
}
|
||||
if (isset($row['儲位/貨道'])) {
|
||||
$row['儲位/貨道'] = (string) $row['儲位/貨道'];
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function model(array $row)
|
||||
{
|
||||
// 查找商品
|
||||
$product = null;
|
||||
if (!empty($row['商品條碼'])) {
|
||||
$product = Product::where('barcode', $row['商品條碼'])->first();
|
||||
}
|
||||
if (!$product && !empty($row['商品代號'])) {
|
||||
$product = Product::where('code', $row['商品代號'])->first();
|
||||
}
|
||||
|
||||
if (!$product) {
|
||||
return null; // 透過 Validation 攔截
|
||||
}
|
||||
|
||||
$quantity = (float) $row['數量'];
|
||||
$unitCost = isset($row['入庫單價']) ? (float) $row['入庫單價'] : ($product->cost_price ?? 0);
|
||||
$location = $row['儲位/貨道'] ?? null;
|
||||
|
||||
// 批號邏輯:若 Excel 留空則使用 NO-BATCH
|
||||
$batchNumber = !empty($row['批號']) ? $row['批號'] : 'NO-BATCH';
|
||||
$originCountry = $row['產地'] ?? 'TW';
|
||||
$expiryDate = !empty($row['效期']) ? $row['效期'] : null;
|
||||
|
||||
return DB::transaction(function () use ($product, $quantity, $unitCost, $location, $batchNumber, $originCountry, $expiryDate) {
|
||||
// 使用與 InventoryController 相同的 firstOrNew 邏輯
|
||||
$inventory = $this->warehouse->inventories()->withTrashed()->firstOrNew(
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => $batchNumber,
|
||||
'location' => $location, // 加入儲位/貨道作為區分關鍵字
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $unitCost,
|
||||
'total_value' => 0,
|
||||
'arrival_date' => $this->inboundDate,
|
||||
'expiry_date' => $expiryDate,
|
||||
'origin_country' => $originCountry,
|
||||
]
|
||||
);
|
||||
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
|
||||
// 更新數量
|
||||
$oldQty = $inventory->quantity;
|
||||
$inventory->quantity += $quantity;
|
||||
|
||||
// 更新單價與總價值
|
||||
$inventory->unit_cost = $unitCost;
|
||||
$inventory->total_value = $inventory->quantity * $unitCost;
|
||||
$inventory->save();
|
||||
|
||||
// 記錄交易歷史
|
||||
$inventory->transactions()->create([
|
||||
'warehouse_id' => $this->warehouse->id,
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => $inventory->batch_number,
|
||||
'quantity' => $quantity,
|
||||
'unit_cost' => $unitCost,
|
||||
'type' => '手動入庫',
|
||||
'reason' => 'Excel 匯入入庫',
|
||||
'balance_before' => $oldQty,
|
||||
'balance_after' => $inventory->quantity,
|
||||
'actual_time' => $this->inboundDate,
|
||||
'notes' => $this->notes,
|
||||
'expiry_date' => $inventory->expiry_date,
|
||||
]);
|
||||
|
||||
return $inventory;
|
||||
});
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'商品條碼' => ['nullable', 'string'],
|
||||
'商品代號' => ['nullable', 'string'],
|
||||
'數量' => [
|
||||
'required_with:商品條碼,商品代號', // 只有在有商品資訊時,數量才是必填
|
||||
'numeric',
|
||||
'min:0' // 允許數量為 0
|
||||
],
|
||||
'入庫單價' => ['nullable', 'numeric', 'min:0'],
|
||||
'儲位/貨道' => ['nullable', 'string', 'max:50'],
|
||||
'批號' => ['nullable', 'string'],
|
||||
'效期' => ['nullable', 'date'],
|
||||
'產地' => ['nullable', 'string', 'max:2'],
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Modules/Inventory/Imports/InventoryTransferItemImport.php
Normal file
131
app/Modules/Inventory/Imports/InventoryTransferItemImport.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Imports;
|
||||
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Exception;
|
||||
|
||||
class InventoryTransferItemImport implements ToCollection, WithMultipleSheets
|
||||
{
|
||||
protected $transferOrder;
|
||||
|
||||
public function __construct(InventoryTransferOrder $transferOrder)
|
||||
{
|
||||
$this->transferOrder = $transferOrder;
|
||||
}
|
||||
|
||||
public function collection(Collection $rows)
|
||||
{
|
||||
if ($rows->isEmpty()) {
|
||||
throw new Exception("檔案中沒有資料。");
|
||||
}
|
||||
|
||||
// 移除標題列並解析索引
|
||||
$headerRow = $rows->shift();
|
||||
$headers = $headerRow->toArray();
|
||||
|
||||
// 建立標題對應索引 (支援中文與英文)
|
||||
$colMap = [
|
||||
'product_code' => -1,
|
||||
'batch_number' => -1,
|
||||
'quantity' => -1,
|
||||
'position' => -1,
|
||||
'notes' => -1,
|
||||
];
|
||||
|
||||
foreach ($headers as $index => $label) {
|
||||
$label = trim((string)$label);
|
||||
if (in_array($label, ['商品代碼', 'product_code', 'shang_pin_dai_ma'])) $colMap['product_code'] = $index;
|
||||
if (in_array($label, ['批號', 'batch_number', 'pi_hao'])) $colMap['batch_number'] = $index;
|
||||
if (in_array($label, ['數量', 'quantity', 'shu_liang'])) $colMap['quantity'] = $index;
|
||||
if (in_array($label, ['貨道/儲位', '貨道', 'position', 'slot', 'huo_dao'])) $colMap['position'] = $index;
|
||||
if (in_array($label, ['備註', 'notes', 'bei_zhu'])) $colMap['notes'] = $index;
|
||||
}
|
||||
|
||||
// 檢查必要欄位是否有找到
|
||||
if ($colMap['product_code'] === -1 || $colMap['quantity'] === -1) {
|
||||
$foundHeaders = implode(', ', array_filter($headers));
|
||||
throw new Exception("找不到必要的欄位「商品代碼」或「數量」。讀取到的標題為:{$foundHeaders}。請確認使用的是正確的範本。");
|
||||
}
|
||||
|
||||
// 預先載入商品 (優化效能)
|
||||
$productCodes = $rows->map(fn($row) => trim((string)($row[$colMap['product_code']] ?? '')))->filter()->unique()->toArray();
|
||||
$products = Product::whereIn('code', $productCodes)->get()->keyBy('code');
|
||||
|
||||
$newItems = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$productCode = trim((string)($row[$colMap['product_code']] ?? ''));
|
||||
$quantity = $row[$colMap['quantity']] ?? null;
|
||||
$batchNumber = $colMap['batch_number'] !== -1 ? trim((string)($row[$colMap['batch_number']] ?? '')) : '';
|
||||
$position = $colMap['position'] !== -1 ? trim((string)($row[$colMap['position']] ?? '')) : null;
|
||||
$notes = $colMap['notes'] !== -1 ? ($row[$colMap['notes']] ?? null) : null;
|
||||
|
||||
// 跳過全空行
|
||||
if (empty($productCode) && ($quantity === null || $quantity === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lineNum = $index + 2; // 因為 shift 過,且 Excel 從 1 開始
|
||||
|
||||
if (empty($productCode)) {
|
||||
$errors[] = "第 {$lineNum} 行:商品代碼不能為空";
|
||||
continue;
|
||||
}
|
||||
|
||||
$product = $products->get($productCode);
|
||||
if (!$product) {
|
||||
$errors[] = "第 {$lineNum} 行:找不到商品代碼 '{$productCode}'";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_numeric($quantity) || (float)$quantity <= 0) {
|
||||
$errors[] = "第 {$lineNum} 行:數量必須為大於 0 的數字 (目前值: " . ($quantity ?? '空') . ")";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($batchNumber)) {
|
||||
$batchNumber = 'NO-BATCH';
|
||||
}
|
||||
|
||||
$newItems[] = [
|
||||
'transfer_order_id' => $this->transferOrder->id,
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => $batchNumber,
|
||||
'quantity' => (float)$quantity,
|
||||
'position' => $position,
|
||||
'notes' => $notes,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
throw new Exception(implode("\n", $errors));
|
||||
}
|
||||
|
||||
if (count($newItems) === 0) {
|
||||
throw new Exception("檔案中沒有可匯入的有效資料。");
|
||||
}
|
||||
|
||||
InventoryTransferItem::insert($newItems);
|
||||
$this->transferOrder->touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定只匯入第一個分頁 (明細匯入)
|
||||
*/
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
0 => $this,
|
||||
];
|
||||
}
|
||||
}
|
||||
177
app/Modules/Inventory/Imports/ProductImport.php
Normal file
177
app/Modules/Inventory/Imports/ProductImport.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Imports;
|
||||
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
|
||||
|
||||
class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapping
|
||||
{
|
||||
private $categories;
|
||||
private $units;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 禁用標題格式化,保留中文標題
|
||||
HeadingRowFormatter::default('none');
|
||||
|
||||
// 快取所有類別與單位,避免 N+1 查詢
|
||||
$this->categories = Category::pluck('id', 'name');
|
||||
$this->units = Unit::pluck('id', 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $row
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
|
||||
if (isset($row['商品代號'])) {
|
||||
$row['商品代號'] = (string) $row['商品代號'];
|
||||
}
|
||||
if (isset($row['條碼'])) {
|
||||
$row['條碼'] = (string) $row['條碼'];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
*/
|
||||
public function model(array $row)
|
||||
{
|
||||
// 查找關聯 ID
|
||||
$categoryId = $this->categories[$row['類別名稱']] ?? null;
|
||||
$baseUnitId = $this->units[$row['基本單位']] ?? null;
|
||||
$largeUnitId = isset($row['大單位']) ? ($this->units[$row['大單位']] ?? null) : null;
|
||||
|
||||
// 若必要關聯找不到,理論上 Validation 會攔截,但此處做防禦性編程
|
||||
if (!$categoryId || !$baseUnitId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = $row['商品代號'] ?? null;
|
||||
$barcode = $row['條碼'] ?? null;
|
||||
|
||||
// Upsert 邏輯:優先以條碼查找,次之以商品代號查找
|
||||
$product = null;
|
||||
if (!empty($barcode)) {
|
||||
$product = Product::where('barcode', $barcode)->first();
|
||||
}
|
||||
|
||||
if (!$product && !empty($code)) {
|
||||
$product = Product::where('code', $code)->first();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $row['商品名稱'],
|
||||
'category_id' => $categoryId,
|
||||
'brand' => $row['品牌'] ?? null,
|
||||
'specification' => $row['規格'] ?? null,
|
||||
'base_unit_id' => $baseUnitId,
|
||||
'large_unit_id' => $largeUnitId,
|
||||
'conversion_rate' => $row['換算率'] ?? null,
|
||||
'purchase_unit_id' => null,
|
||||
'cost_price' => $row['成本價'] ?? null,
|
||||
'price' => $row['售價'] ?? null,
|
||||
'member_price' => $row['會員價'] ?? null,
|
||||
'wholesale_price' => $row['批發價'] ?? null,
|
||||
];
|
||||
|
||||
if ($product) {
|
||||
// 更新現有商品
|
||||
$product->update($data);
|
||||
return null; // 返回 null 以避免 Maatwebsite/Excel 嘗試再次 insert
|
||||
}
|
||||
|
||||
// 建立新商品:處理代碼與條碼自動生成
|
||||
if (empty($code)) {
|
||||
$code = $this->generateRandomCode();
|
||||
}
|
||||
if (empty($barcode)) {
|
||||
$barcode = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
$data['code'] = $code;
|
||||
$data['barcode'] = $barcode;
|
||||
|
||||
return new Product($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成隨機 8 碼代號 (大寫英文+數字)
|
||||
*/
|
||||
private function generateRandomCode(): string
|
||||
{
|
||||
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$code = '';
|
||||
|
||||
do {
|
||||
$code = '';
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$code .= $characters[rand(0, strlen($characters) - 1)];
|
||||
}
|
||||
} while (Product::where('code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成隨機 13 碼條碼 (純數字)
|
||||
*/
|
||||
private function generateRandomBarcode(): string
|
||||
{
|
||||
$barcode = '';
|
||||
|
||||
do {
|
||||
$barcode = '';
|
||||
for ($i = 0; $i < 13; $i++) {
|
||||
$barcode .= rand(0, 9);
|
||||
}
|
||||
} while (Product::where('barcode', $barcode)->exists());
|
||||
|
||||
return $barcode;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'商品代號' => ['nullable', 'string', 'min:2', 'max:8'],
|
||||
'條碼' => ['nullable', 'string'],
|
||||
'商品名稱' => ['required', 'string'],
|
||||
'類別名稱' => ['required', function($attribute, $value, $fail) {
|
||||
if (!isset($this->categories[$value])) {
|
||||
$fail("找不到類別: " . $value);
|
||||
}
|
||||
}],
|
||||
'基本單位' => ['required', function($attribute, $value, $fail) {
|
||||
if (!isset($this->units[$value])) {
|
||||
$fail("找不到單位: " . $value);
|
||||
}
|
||||
}],
|
||||
'大單位' => ['nullable', function($attribute, $value, $fail) {
|
||||
if ($value && !isset($this->units[$value])) {
|
||||
$fail("找不到單位: " . $value);
|
||||
}
|
||||
}],
|
||||
|
||||
'換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'],
|
||||
'成本價' => ['nullable', 'numeric', 'min:0'],
|
||||
'售價' => ['nullable', 'numeric', 'min:0'],
|
||||
'會員價' => ['nullable', 'numeric', 'min:0'],
|
||||
'批發價' => ['nullable', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,16 @@ namespace App\Modules\Inventory;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
use App\Modules\Inventory\Services\ProductService;
|
||||
|
||||
class InventoryServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
||||
$this->app->bind(ProductServiceInterface::class, ProductService::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
51
app/Modules/Inventory/Models/GoodsReceipt.php
Normal file
51
app/Modules/Inventory/Models/GoodsReceipt.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class GoodsReceipt extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'type',
|
||||
'warehouse_id',
|
||||
'purchase_order_id',
|
||||
'vendor_id',
|
||||
'received_date',
|
||||
'status',
|
||||
'remarks',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'received_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(GoodsReceiptItem::class);
|
||||
}
|
||||
|
||||
// Strict Mode: relationships to Warehouse is allowed (same module).
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
// Strict Mode: cross-module relationship to Vendor/User/PurchaseOrder is restricted.
|
||||
// They are accessed via IDs or Services.
|
||||
}
|
||||
39
app/Modules/Inventory/Models/GoodsReceiptItem.php
Normal file
39
app/Modules/Inventory/Models/GoodsReceiptItem.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class GoodsReceiptItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'goods_receipt_id',
|
||||
'product_id',
|
||||
'purchase_order_item_id',
|
||||
'quantity_received',
|
||||
'unit_price',
|
||||
'total_amount',
|
||||
'batch_number',
|
||||
'expiry_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_received' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2', // 暫定價格
|
||||
'total_amount' => 'decimal:2',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function goodsReceipt()
|
||||
{
|
||||
return $this->belongsTo(GoodsReceipt::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
144
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
144
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class InventoryAdjustDoc extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'count_doc_id',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'reason',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'ADJ-' . $today . '-';
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function countDoc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryAdjustItem::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->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 key information
|
||||
$snapshot['doc_no'] = $this->doc_no;
|
||||
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
|
||||
$snapshot['posted_at'] = $this->posted_at ? $this->posted_at->format('Y-m-d H:i:s') : null;
|
||||
$snapshot['status'] = $this->status;
|
||||
$snapshot['created_by_name'] = $this->createdBy ? $this->createdBy->name : null;
|
||||
$snapshot['posted_by_name'] = $this->postedBy ? $this->postedBy->name : null;
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 全域 ID 轉名稱邏輯 (用於 attributes 與 old)
|
||||
$convertIdsToNames = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 倉庫 ID 轉換
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id']);
|
||||
if ($warehouse) {
|
||||
$data['warehouse_id'] = $warehouse->name;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用者 ID 轉換
|
||||
$userFields = ['created_by', 'updated_by', 'posted_by'];
|
||||
foreach ($userFields as $field) {
|
||||
if (isset($data[$field]) && is_numeric($data[$field])) {
|
||||
$user = \App\Modules\Core\Models\User::find($data[$field]);
|
||||
if ($user) {
|
||||
$data[$field] = $user->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) {
|
||||
$convertIdsToNames($properties['attributes']);
|
||||
}
|
||||
if (isset($properties['old'])) {
|
||||
$convertIdsToNames($properties['old']);
|
||||
}
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryAdjustItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'adjust_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'qty_before',
|
||||
'adjust_qty', // 增減數量
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty_before' => 'decimal:2',
|
||||
'adjust_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryAdjustDoc::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
141
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
141
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class InventoryCountDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'snapshot_date',
|
||||
'completed_at',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'completed_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_date' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'CNT-' . $today . '-';
|
||||
|
||||
// 查詢當天編號最大的單據
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
// 取得最後兩位序號並遞增
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryCountItem::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function completedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'completed_by');
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logFillable()
|
||||
->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 key information
|
||||
$snapshot['doc_no'] = $this->doc_no;
|
||||
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
|
||||
$snapshot['completed_at'] = $this->completed_at ? $this->completed_at->format('Y-m-d H:i:s') : null;
|
||||
$snapshot['status'] = $this->status;
|
||||
$snapshot['created_by_name'] = $this->createdBy ? $this->createdBy->name : null;
|
||||
$snapshot['completed_by_name'] = $this->completedBy ? $this->completedBy->name : null;
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 全域 ID 轉名稱邏輯 (用於 attributes 與 old)
|
||||
$convertIdsToNames = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 倉庫 ID 轉換
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id']);
|
||||
if ($warehouse) {
|
||||
$data['warehouse_id'] = $warehouse->name;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用者 ID 轉換
|
||||
$userFields = ['created_by', 'updated_by', 'completed_by'];
|
||||
foreach ($userFields as $field) {
|
||||
if (isset($data[$field]) && is_numeric($data[$field])) {
|
||||
$user = \App\Modules\Core\Models\User::find($data[$field]);
|
||||
if ($user) {
|
||||
$data[$field] = $user->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) {
|
||||
$convertIdsToNames($properties['attributes']);
|
||||
}
|
||||
if (isset($properties['old'])) {
|
||||
$convertIdsToNames($properties['old']);
|
||||
}
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryCountItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'count_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'system_qty',
|
||||
'counted_qty',
|
||||
'diff_qty',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'system_qty' => 'decimal:2',
|
||||
'counted_qty' => 'decimal:2',
|
||||
'diff_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@ class InventoryTransaction extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'actual_time' => 'datetime',
|
||||
// actual_time 不做時區轉換,保留原始字串格式(台北時間)
|
||||
// 原因:資料庫儲存的是台北時間,但 MySQL 時區為 UTC
|
||||
// 若使用 datetime cast,Laravel 會誤當作 UTC 再轉回台北時間,造成偏移
|
||||
'unit_cost' => 'decimal:4',
|
||||
];
|
||||
|
||||
|
||||
36
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
36
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryTransferItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'transfer_order_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'position',
|
||||
'snapshot_quantity',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
197
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
197
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryTransferOrder extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array 暫存的活動紀錄屬性 (不會存入資料庫)
|
||||
*/
|
||||
public $activityProperties = [];
|
||||
|
||||
/**
|
||||
* 自定義日誌屬性名稱解析
|
||||
*/
|
||||
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties->toArray();
|
||||
|
||||
// 處置日誌事件說明
|
||||
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'; // 供後續快照邏輯判定
|
||||
} else {
|
||||
$activity->description = 'updated';
|
||||
}
|
||||
}
|
||||
|
||||
// 處理倉庫 ID 轉名稱
|
||||
$idToNameFields = [
|
||||
'from_warehouse_id' => 'fromWarehouse',
|
||||
'to_warehouse_id' => 'toWarehouse',
|
||||
'created_by' => 'createdBy',
|
||||
'posted_by' => 'postedBy',
|
||||
];
|
||||
|
||||
foreach (['attributes', 'old'] as $part) {
|
||||
if (isset($properties[$part])) {
|
||||
foreach ($idToNameFields as $idField => $relation) {
|
||||
if (isset($properties[$part][$idField])) {
|
||||
$id = $properties[$part][$idField];
|
||||
$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";
|
||||
}
|
||||
$properties[$part][$nameField] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 基本單據資訊快照 (包含單號、來源、目的地)
|
||||
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
|
||||
$properties['snapshot'] = [
|
||||
'doc_no' => $this->doc_no,
|
||||
'from_warehouse_name' => $this->fromWarehouse?->name,
|
||||
'to_warehouse_name' => $this->toWarehouse?->name,
|
||||
'status' => $this->status,
|
||||
];
|
||||
}
|
||||
|
||||
// 移除輔助欄位與雜訊
|
||||
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']);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'from_warehouse_id',
|
||||
'to_warehouse_id',
|
||||
'transit_warehouse_id',
|
||||
'status',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
'dispatched_at',
|
||||
'dispatched_by',
|
||||
'received_at',
|
||||
'received_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
'dispatched_at' => 'datetime',
|
||||
'received_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'TRF-' . $today . '-';
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function fromWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'from_warehouse_id');
|
||||
}
|
||||
|
||||
public function toWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'to_warehouse_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryTransferItem::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function storeRequisition(): \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
{
|
||||
return $this->hasOne(StoreRequisition::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
|
||||
public function transitWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'transit_warehouse_id');
|
||||
}
|
||||
|
||||
public function dispatchedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'dispatched_by');
|
||||
}
|
||||
|
||||
public function receivedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'received_by');
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ class Product extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'barcode',
|
||||
'name',
|
||||
'external_pos_id',
|
||||
'category_id',
|
||||
'brand',
|
||||
'specification',
|
||||
@@ -25,10 +27,17 @@ class Product extends Model
|
||||
'large_unit_id',
|
||||
'conversion_rate',
|
||||
'purchase_unit_id',
|
||||
'location',
|
||||
'cost_price',
|
||||
'price',
|
||||
'member_price',
|
||||
'wholesale_price',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'conversion_rate' => 'decimal:4',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class StoreRequisition extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'store_warehouse_id',
|
||||
'supply_warehouse_id',
|
||||
'status',
|
||||
'remark',
|
||||
'reject_reason',
|
||||
'created_by',
|
||||
'approved_by',
|
||||
'submitted_at',
|
||||
'approved_at',
|
||||
'transfer_order_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'submitted_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定義日誌屬性,解析 ID 為名稱
|
||||
*/
|
||||
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties->toArray();
|
||||
|
||||
// 基本單據資訊快照
|
||||
$properties['snapshot'] = [
|
||||
'doc_no' => $this->doc_no,
|
||||
'store_warehouse_name' => $this->storeWarehouse?->name,
|
||||
'supply_warehouse_name' => $this->supplyWarehouse?->name,
|
||||
'status' => $this->status,
|
||||
];
|
||||
|
||||
// 移除雜訊欄位
|
||||
if (isset($properties['attributes'])) {
|
||||
unset($properties['attributes']['updated_at']);
|
||||
}
|
||||
if (isset($properties['old'])) {
|
||||
unset($properties['old']['updated_at']);
|
||||
}
|
||||
|
||||
$activity->properties = collect($properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動產生單號 SR-YYYYMMDD-XX
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'SR-' . $today . '-';
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 關聯 =====
|
||||
|
||||
/**
|
||||
* 申請倉庫
|
||||
*/
|
||||
public function storeWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'store_warehouse_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 供貨倉庫(審核時填入)
|
||||
*/
|
||||
public function supplyWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'supply_warehouse_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫貨明細
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(StoreRequisitionItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 申請人
|
||||
*/
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 審核人
|
||||
*/
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯調撥單
|
||||
*/
|
||||
public function transferOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
|
||||
}
|
||||
}
|
||||
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal file
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StoreRequisitionItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'store_requisition_id',
|
||||
'product_id',
|
||||
'requested_qty',
|
||||
'approved_qty',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'requested_qty' => 'decimal:2',
|
||||
'approved_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬叫貨單
|
||||
*/
|
||||
public function requisition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreRequisition::class, 'store_requisition_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯商品(同模組)
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class Unit extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = ['name', 'abbreviation'];
|
||||
protected $fillable = ['name', 'code'];
|
||||
|
||||
public function productsAsBase(): HasMany
|
||||
{
|
||||
|
||||
@@ -18,13 +18,12 @@ class Warehouse extends Model
|
||||
'type',
|
||||
'address',
|
||||
'description',
|
||||
'is_sellable',
|
||||
'license_plate',
|
||||
'driver_name',
|
||||
'default_transit_warehouse_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_sellable' => 'boolean',
|
||||
'type' => \App\Enums\WarehouseType::class,
|
||||
];
|
||||
|
||||
@@ -52,7 +51,13 @@ class Warehouse extends Model
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 預設在途倉庫
|
||||
*/
|
||||
public function defaultTransitWarehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'default_transit_warehouse_id');
|
||||
}
|
||||
|
||||
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Modules\Inventory\Models\StoreRequisition;
|
||||
|
||||
class StoreRequisitionNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected StoreRequisition $requisition;
|
||||
protected string $action;
|
||||
protected string $actorName;
|
||||
|
||||
/**
|
||||
* 建立通知實例
|
||||
*
|
||||
* @param StoreRequisition $requisition 叫貨單
|
||||
* @param string $action 操作類型:submitted / approved / rejected
|
||||
* @param string $actorName 操作者名稱
|
||||
*/
|
||||
public function __construct(StoreRequisition $requisition, string $action, string $actorName)
|
||||
{
|
||||
$this->requisition = $requisition;
|
||||
$this->action = $action;
|
||||
$this->actorName = $actorName;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$messages = [
|
||||
'submitted' => "{$this->actorName} 提交了叫貨申請:{$this->requisition->doc_no}",
|
||||
'approved' => "{$this->actorName} 核准了叫貨申請:{$this->requisition->doc_no}",
|
||||
'rejected' => "{$this->actorName} 駁回了叫貨申請:{$this->requisition->doc_no}",
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'store_requisition',
|
||||
'action' => $this->action,
|
||||
'store_requisition_id' => $this->requisition->id,
|
||||
'doc_no' => $this->requisition->doc_no,
|
||||
'actor_name' => $this->actorName,
|
||||
'message' => $messages[$this->action] ?? "{$this->actorName} 操作了叫貨申請:{$this->requisition->doc_no}",
|
||||
'link' => route('store-requisitions.show', $this->requisition->id),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,36 @@ use App\Modules\Inventory\Controllers\WarehouseController;
|
||||
use App\Modules\Inventory\Controllers\InventoryController;
|
||||
use App\Modules\Inventory\Controllers\SafetyStockController;
|
||||
use App\Modules\Inventory\Controllers\TransferOrderController;
|
||||
use App\Modules\Inventory\Controllers\CountDocController;
|
||||
use App\Modules\Inventory\Controllers\AdjustDocController;
|
||||
|
||||
use App\Modules\Inventory\Controllers\InventoryReportController;
|
||||
|
||||
use App\Modules\Inventory\Controllers\StockQueryController;
|
||||
use App\Modules\Inventory\Controllers\InventoryAnalysisController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
// 即時庫存查詢
|
||||
Route::middleware('permission:inventory.view')->group(function () {
|
||||
Route::get('/inventory/stock-query', [StockQueryController::class, 'index'])->name('inventory.stock-query.index');
|
||||
Route::get('/inventory/stock-query/export', [StockQueryController::class, 'export'])->name('inventory.stock-query.export');
|
||||
});
|
||||
|
||||
// 庫存報表
|
||||
Route::middleware('permission:inventory_report.view')->group(function () {
|
||||
Route::get('/inventory/report', [InventoryReportController::class, 'index'])->name('inventory.report.index');
|
||||
Route::get('/inventory/report/export', [InventoryReportController::class, 'export'])
|
||||
->middleware('permission:inventory_report.export')
|
||||
->name('inventory.report.export');
|
||||
Route::get('/inventory/report/{product}', [InventoryReportController::class, 'show'])->name('inventory.report.show');
|
||||
});
|
||||
|
||||
// 庫存分析 (Inventory Analysis)
|
||||
Route::middleware('permission:inventory_report.view')->group(function () {
|
||||
Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index');
|
||||
});
|
||||
|
||||
// 類別管理 (用於商品對話框) - 需要商品權限
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||
@@ -20,15 +47,20 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
|
||||
// 單位管理 - 需要商品權限
|
||||
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||
Route::post('/units', [UnitController::class, 'store'])->name('units.store');
|
||||
Route::put('/units/{unit}', [UnitController::class, 'update'])->name('units.update');
|
||||
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->name('units.destroy');
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::post('/units', [UnitController::class, 'store'])->middleware('permission:products.create')->name('units.store');
|
||||
Route::put('/units/{unit}', [UnitController::class, 'update'])->middleware('permission:products.edit')->name('units.update');
|
||||
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->middleware('permission:products.delete')->name('units.destroy');
|
||||
});
|
||||
|
||||
// 商品管理
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/products/template', [ProductController::class, 'template'])->name('products.template');
|
||||
Route::post('/products/import', [ProductController::class, 'import'])->middleware('permission:products.create')->name('products.import');
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
||||
Route::get('/products/create', [ProductController::class, 'create'])->middleware('permission:products.create')->name('products.create');
|
||||
Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
|
||||
Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->middleware('permission:products.edit')->name('products.edit');
|
||||
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
|
||||
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');
|
||||
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete')->name('products.destroy');
|
||||
@@ -49,12 +81,14 @@ Route::middleware('auth')->group(function () {
|
||||
Route::middleware('permission:inventory.adjust')->group(function () {
|
||||
Route::get('/warehouses/{warehouse}/inventory/create', [InventoryController::class, 'create'])->name('warehouses.inventory.create');
|
||||
Route::post('/warehouses/{warehouse}/inventory', [InventoryController::class, 'store'])->name('warehouses.inventory.store');
|
||||
Route::get('/warehouses/inventory/template', [InventoryController::class, 'template'])->name('warehouses.inventory.template');
|
||||
Route::post('/warehouses/{warehouse}/inventory/import', [InventoryController::class, 'import'])->name('warehouses.inventory.import');
|
||||
Route::get('/warehouses/{warehouse}/inventory/{inventoryId}/edit', [InventoryController::class, 'edit'])->name('warehouses.inventory.edit');
|
||||
Route::put('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'update'])->name('warehouses.inventory.update');
|
||||
Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy');
|
||||
});
|
||||
|
||||
// API: 取得商品在特定倉庫的所有批號
|
||||
// API: 取得商品在特定倉庫的所有批號
|
||||
Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches'])
|
||||
->name('api.warehouses.inventory.batches');
|
||||
});
|
||||
@@ -70,11 +104,83 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 撥補單 (在庫存調撥時使用)
|
||||
Route::middleware('permission:inventory.transfer')->group(function () {
|
||||
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
||||
// 庫存盤點 (Stock Counting) - Global
|
||||
Route::middleware('permission:inventory_count.view')->group(function () {
|
||||
Route::get('/inventory/count-docs', [CountDocController::class, 'index'])->name('inventory.count.index');
|
||||
Route::get('/inventory/count-docs/{doc}', [CountDocController::class, 'show'])->name('inventory.count.show');
|
||||
Route::get('/inventory/count-docs/{doc}/print', [CountDocController::class, 'print'])->name('inventory.count.print');
|
||||
});
|
||||
Route::post('/inventory/count-docs', [CountDocController::class, 'store'])->middleware('permission:inventory_count.create')->name('inventory.count.store');
|
||||
Route::put('/inventory/count-docs/{doc}', [CountDocController::class, 'update'])->middleware('permission:inventory_count.edit')->name('inventory.count.update');
|
||||
Route::delete('/inventory/count-docs/{doc}', [CountDocController::class, 'destroy'])->middleware('permission:inventory_count.delete')->name('inventory.count.destroy');
|
||||
Route::put('/inventory/count-docs/{doc}/reopen', [CountDocController::class, 'reopen'])->middleware('permission:inventory_count.edit')->name('inventory.count.reopen');
|
||||
|
||||
// 庫存盤調 (Stock Adjustment) - Global
|
||||
Route::middleware('permission:inventory_adjust.view')->group(function () {
|
||||
Route::get('/inventory/adjust-docs', [AdjustDocController::class, 'index'])->name('inventory.adjust.index');
|
||||
Route::get('/inventory/adjust-docs/get-pending-counts', [AdjustDocController::class, 'getPendingCounts'])->name('inventory.adjust.pending-counts');
|
||||
Route::get('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'show'])->name('inventory.adjust.show');
|
||||
});
|
||||
Route::post('/inventory/adjust-docs', [AdjustDocController::class, 'store'])->middleware('permission:inventory_adjust.create')->name('inventory.adjust.store');
|
||||
Route::put('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'update'])->middleware('permission:inventory_adjust.edit')->name('inventory.adjust.update');
|
||||
Route::delete('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'destroy'])->middleware('permission:inventory_adjust.delete')->name('inventory.adjust.destroy');
|
||||
|
||||
// 撥補單/調撥單 (Transfer Order) - Global
|
||||
Route::middleware('permission:inventory_transfer.view')->group(function () {
|
||||
Route::get('/inventory/transfer-orders', [TransferOrderController::class, 'index'])->name('inventory.transfer.index');
|
||||
Route::get('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'show'])->name('inventory.transfer.show');
|
||||
});
|
||||
Route::post('/inventory/transfer-orders', [TransferOrderController::class, 'store'])->middleware('permission:inventory_transfer.create')->name('inventory.transfer.store');
|
||||
Route::put('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'update'])->middleware('permission:inventory_transfer.edit')->name('inventory.transfer.update');
|
||||
Route::delete('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'destroy'])->middleware('permission:inventory_transfer.delete')->name('inventory.transfer.destroy');
|
||||
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
|
||||
->middleware('permission:inventory.view')
|
||||
->name('api.warehouses.inventories');
|
||||
|
||||
// 調撥單匯入明細
|
||||
Route::post('/inventory/transfer-orders/{order}/import', [TransferOrderController::class, 'importItems'])
|
||||
->middleware('permission:inventory_transfer.edit')
|
||||
->name('inventory.transfer.import-items');
|
||||
|
||||
// 下載調撥單匯入範本
|
||||
Route::get('/inventory/transfer-orders/template/download', [TransferOrderController::class, 'template'])
|
||||
->middleware('permission:inventory_transfer.view')
|
||||
->name('inventory.transfer.template');
|
||||
|
||||
// 門市叫貨申請 (Store Requisitions)
|
||||
Route::middleware('permission:store_requisitions.view')->group(function () {
|
||||
Route::get('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'index'])->name('store-requisitions.index');
|
||||
|
||||
Route::middleware('permission:store_requisitions.create')->group(function () {
|
||||
Route::get('/store-requisitions/create', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'create'])->name('store-requisitions.create');
|
||||
Route::post('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'store'])->name('store-requisitions.store');
|
||||
});
|
||||
|
||||
Route::get('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'show'])->name('store-requisitions.show');
|
||||
|
||||
Route::middleware('permission:store_requisitions.edit')->group(function () {
|
||||
Route::get('/store-requisitions/{id}/edit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'edit'])->name('store-requisitions.edit');
|
||||
Route::put('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'update'])->name('store-requisitions.update');
|
||||
});
|
||||
|
||||
Route::post('/store-requisitions/{id}/submit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'submit'])->name('store-requisitions.submit');
|
||||
|
||||
Route::middleware('permission:store_requisitions.approve')->group(function () {
|
||||
Route::post('/store-requisitions/{id}/approve', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'approve'])->name('store-requisitions.approve');
|
||||
Route::post('/store-requisitions/{id}/reject', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'reject'])->name('store-requisitions.reject');
|
||||
});
|
||||
|
||||
Route::delete('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'destroy'])->middleware('permission:store_requisitions.delete')->name('store-requisitions.destroy');
|
||||
});
|
||||
|
||||
// 進貨單 (Goods Receipts)
|
||||
Route::middleware('permission:goods_receipts.view')->group(function () {
|
||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
||||
Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create');
|
||||
Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show');
|
||||
Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
|
||||
Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos');
|
||||
Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products');
|
||||
Route::get('/api/goods-receipts/search-vendors', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchVendors'])->name('goods-receipts.search-vendors');
|
||||
});
|
||||
});
|
||||
|
||||
265
app/Modules/Inventory/Services/AdjustService.php
Normal file
265
app/Modules/Inventory/Services/AdjustService.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdjustService
|
||||
{
|
||||
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
|
||||
{
|
||||
return InventoryAdjustDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'count_doc_id' => $countDocId,
|
||||
'status' => 'draft',
|
||||
'reason' => $reason,
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從盤點單建立盤調單
|
||||
*/
|
||||
public function createFromCountDoc(InventoryCountDoc $countDoc, int $userId): InventoryAdjustDoc
|
||||
{
|
||||
return DB::transaction(function () use ($countDoc, $userId) {
|
||||
// 1. 建立盤調單頭
|
||||
$adjDoc = $this->createDoc(
|
||||
$countDoc->warehouse_id,
|
||||
"盤點調整: " . $countDoc->doc_no,
|
||||
"由盤點單 {$countDoc->doc_no} 自動生成",
|
||||
$userId,
|
||||
$countDoc->id
|
||||
);
|
||||
|
||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||
foreach ($countDoc->items as $item) {
|
||||
if (abs($item->diff_qty) < 0.0001) continue;
|
||||
|
||||
$adjDoc->items()->create([
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'qty_before' => $item->system_qty,
|
||||
'adjust_qty' => $item->diff_qty,
|
||||
'notes' => "盤點差異: " . $item->diff_qty,
|
||||
]);
|
||||
}
|
||||
|
||||
return $adjDoc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新盤調單內容 (Items)
|
||||
* 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠
|
||||
*/
|
||||
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
$updatedItems = [];
|
||||
$oldItems = $doc->items()->with('product')->get();
|
||||
|
||||
// 記錄舊品項狀態 (用於標註異動)
|
||||
foreach ($oldItems as $oldItem) {
|
||||
$updatedItems[] = [
|
||||
'product_name' => $oldItem->product->name,
|
||||
'old' => [
|
||||
'adjust_qty' => (float)$oldItem->adjust_qty,
|
||||
'notes' => $oldItem->notes,
|
||||
],
|
||||
'new' => null // 標記為刪除或待更新
|
||||
];
|
||||
}
|
||||
|
||||
$doc->items()->delete();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->where('product_id', $data['product_id'])
|
||||
->where('batch_number', $data['batch_number'] ?? null)
|
||||
->first();
|
||||
|
||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||
|
||||
$newItem = $doc->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'qty_before' => $qtyBefore,
|
||||
'adjust_qty' => $data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// 更新日誌中的品項列表
|
||||
$productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
|
||||
$found = false;
|
||||
foreach ($updatedItems as $idx => $ui) {
|
||||
if ($ui['product_name'] === $productName && $ui['new'] === null) {
|
||||
$updatedItems[$idx]['new'] = [
|
||||
'adjust_qty' => (float)$data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
];
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
$updatedItems[] = [
|
||||
'product_name' => $productName,
|
||||
'old' => null,
|
||||
'new' => [
|
||||
'adjust_qty' => (float)$data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 清理沒被更新到的舊品項 (即真正被刪除的)
|
||||
$finalUpdatedItems = [];
|
||||
foreach ($updatedItems as $ui) {
|
||||
if ($ui['old'] === null && $ui['new'] === null) continue;
|
||||
// 比對是否有實質變動
|
||||
if ($ui['old'] != $ui['new']) {
|
||||
$finalUpdatedItems[] = $ui;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($finalUpdatedItems)) {
|
||||
activity()
|
||||
->performedOn($doc)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'items_diff' => [
|
||||
'updated' => $finalUpdatedItems,
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 生效庫存異動
|
||||
*/
|
||||
public function post(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
$oldStatus = $doc->status;
|
||||
|
||||
foreach ($doc->items as $item) {
|
||||
if ($item->adjust_qty == 0) continue;
|
||||
|
||||
$inventory = Inventory::firstOrNew([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
]);
|
||||
|
||||
// 如果是新建立的 object (id 為空),需要初始化 default
|
||||
if (!$inventory->exists) {
|
||||
$inventory->unit_cost = $item->product->cost ?? 0;
|
||||
$inventory->quantity = 0;
|
||||
}
|
||||
|
||||
$oldQty = $inventory->quantity;
|
||||
$newQty = $oldQty + $item->adjust_qty;
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
$inventory->total_value = $newQty * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 建立 Transaction
|
||||
$inventory->transactions()->create([
|
||||
'type' => '庫存調整',
|
||||
'quantity' => $item->adjust_qty,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $oldQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
// 使用 saveQuietly 避免重複產生自動日誌
|
||||
$doc->status = 'posted';
|
||||
$doc->posted_at = now();
|
||||
$doc->posted_by = $userId;
|
||||
$doc->saveQuietly();
|
||||
|
||||
// 準備品項快照供日誌使用
|
||||
$itemsSnapshot = $doc->items->map(function($item) {
|
||||
return [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => null, // 過帳視為整單生效,不顯示個別欄位差異
|
||||
'new' => [
|
||||
'adjust_qty' => (float)$item->adjust_qty,
|
||||
'notes' => $item->notes,
|
||||
]
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// 手動產生過帳日誌
|
||||
activity()
|
||||
->performedOn($doc)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => [
|
||||
'status' => 'posted',
|
||||
'posted_at' => $doc->posted_at->format('Y-m-d H:i:s'),
|
||||
'posted_by' => $userId,
|
||||
],
|
||||
'old' => [
|
||||
'status' => $oldStatus,
|
||||
'posted_at' => null,
|
||||
'posted_by' => null,
|
||||
],
|
||||
'items_diff' => [
|
||||
'updated' => $itemsSnapshot,
|
||||
]
|
||||
])
|
||||
->log('posted');
|
||||
|
||||
// 4. 若關聯盤點單,連動更新盤點單狀態
|
||||
if ($doc->count_doc_id) {
|
||||
$countDoc = InventoryCountDoc::find($doc->count_doc_id);
|
||||
if ($countDoc) {
|
||||
$countDoc->status = 'adjusted';
|
||||
$countDoc->saveQuietly(); // 盤點單也靜默更新
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作廢 (Void)
|
||||
*/
|
||||
public function void(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
|
||||
$oldStatus = $doc->status;
|
||||
$doc->status = 'voided';
|
||||
$doc->updated_by = $userId;
|
||||
$doc->saveQuietly();
|
||||
|
||||
activity()
|
||||
->performedOn($doc)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => ['status' => 'voided'],
|
||||
'old' => ['status' => $oldStatus]
|
||||
])
|
||||
->log('voided');
|
||||
}
|
||||
}
|
||||
208
app/Modules/Inventory/Services/CountService.php
Normal file
208
app/Modules/Inventory/Services/CountService.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryCountItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CountService
|
||||
{
|
||||
/**
|
||||
* 建立新的盤點單並執行快照
|
||||
*/
|
||||
public function createDoc(string $warehouseId, string $remarks = null, int $userId): InventoryCountDoc
|
||||
{
|
||||
return DB::transaction(function () use ($warehouseId, $remarks, $userId) {
|
||||
$doc = InventoryCountDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'status' => 'counting',
|
||||
'snapshot_date' => now(),
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return $doc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行快照:鎖定當前庫存量
|
||||
*/
|
||||
public function snapshot(InventoryCountDoc $doc, bool $updateDoc = true): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $updateDoc) {
|
||||
// 清除舊的 items (如果有)
|
||||
$doc->items()->delete();
|
||||
|
||||
// 取得該倉庫所有庫存 (包含 quantity = 0 但未軟刪除的)
|
||||
// 這裡可以根據需求決定是否要過濾掉 0 庫存,通常盤點單會希望能看到所有 "帳上有紀錄" 的東西
|
||||
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
foreach ($inventories as $inv) {
|
||||
$items[] = [
|
||||
'count_doc_id' => $doc->id,
|
||||
'product_id' => $inv->product_id,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'system_qty' => $inv->quantity,
|
||||
'counted_qty' => null, // 預設未盤點
|
||||
'diff_qty' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($items)) {
|
||||
InventoryCountItem::insert($items);
|
||||
}
|
||||
|
||||
if ($updateDoc) {
|
||||
$doc->update([
|
||||
'status' => 'counting',
|
||||
'snapshot_date' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成盤點:過帳差異
|
||||
*/
|
||||
public function complete(InventoryCountDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
// 僅更新單據狀態為「已完成」,不執行庫存入庫/調整
|
||||
// 盤點單僅作為記錄,後續調整由盤調單 (AdjustDoc) 執行
|
||||
$doc->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'completed_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新盤點數量
|
||||
*/
|
||||
public function updateCount(InventoryCountDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
$updatedItems = [];
|
||||
$hasChanges = false;
|
||||
$oldDocAttributes = [
|
||||
'status' => $doc->status,
|
||||
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i:s') : null,
|
||||
'completed_by' => $doc->completed_by,
|
||||
];
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
$item = $doc->items()->with('product')->find($data['id']);
|
||||
if ($item) {
|
||||
$oldQty = $item->counted_qty;
|
||||
$newQty = $data['counted_qty'];
|
||||
$oldNotes = $item->notes;
|
||||
$newNotes = $data['notes'] ?? $item->notes;
|
||||
|
||||
$isQtyChanged = $oldQty != $newQty;
|
||||
$isNotesChanged = $oldNotes !== $newNotes;
|
||||
|
||||
if ($isQtyChanged || $isNotesChanged) {
|
||||
$updatedItems[] = [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
'counted_qty' => $oldQty,
|
||||
'notes' => $oldNotes,
|
||||
],
|
||||
'new' => [
|
||||
'counted_qty' => $newQty,
|
||||
'notes' => $newNotes,
|
||||
]
|
||||
];
|
||||
|
||||
$countedQty = $data['counted_qty'];
|
||||
$diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0;
|
||||
|
||||
$item->update([
|
||||
'counted_qty' => $countedQty,
|
||||
'diff_qty' => $diff,
|
||||
'notes' => $newNotes,
|
||||
]);
|
||||
$hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查是否完成
|
||||
$doc->refresh();
|
||||
$isAllCounted = $doc->items()->whereNull('counted_qty')->count() === 0;
|
||||
$newDocAttributesLog = [];
|
||||
|
||||
if ($isAllCounted) {
|
||||
// 檢查是否有任何差異
|
||||
$hasDiff = $doc->items()->where('diff_qty', '!=', 0)->exists();
|
||||
$targetStatus = $hasDiff ? 'completed' : 'no_adjust';
|
||||
|
||||
if ($doc->status !== $targetStatus) {
|
||||
$doc->status = $targetStatus;
|
||||
$doc->completed_at = now();
|
||||
$doc->completed_by = auth()->id();
|
||||
$doc->saveQuietly();
|
||||
|
||||
$doc->refresh();
|
||||
|
||||
$newDocAttributesLog = [
|
||||
'status' => $targetStatus,
|
||||
'completed_at' => $doc->completed_at->format('Y-m-d H:i:s'),
|
||||
'completed_by' => $doc->completed_by,
|
||||
];
|
||||
$hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
if ($doc->status === 'completed') {
|
||||
$doc->status = 'counting';
|
||||
$doc->completed_at = null;
|
||||
$doc->completed_by = null;
|
||||
$doc->saveQuietly();
|
||||
|
||||
$newDocAttributesLog = [
|
||||
'status' => 'counting',
|
||||
'completed_at' => null,
|
||||
'completed_by' => null,
|
||||
];
|
||||
$hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 記錄操作日誌
|
||||
if ($hasChanges) {
|
||||
$properties = [
|
||||
'items_diff' => [
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'updated' => $updatedItems,
|
||||
],
|
||||
];
|
||||
|
||||
// 如果有文件層級的屬性變更 (狀態),併入 log
|
||||
if (!empty($newDocAttributesLog)) {
|
||||
$properties['attributes'] = $newDocAttributesLog;
|
||||
$properties['old'] = array_intersect_key($oldDocAttributes, $newDocAttributesLog);
|
||||
}
|
||||
|
||||
activity()
|
||||
->performedOn($doc)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties($properties)
|
||||
->log('updated');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
109
app/Modules/Inventory/Services/GoodsReceiptService.php
Normal file
109
app/Modules/Inventory/Services/GoodsReceiptService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GoodsReceiptService
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Goods Receipt and process inventory.
|
||||
*
|
||||
* @param array $data
|
||||
* @return GoodsReceipt
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function store(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// 1. Generate Code
|
||||
$data['code'] = $this->generateCode($data['received_date']);
|
||||
$data['user_id'] = auth()->id();
|
||||
$data['status'] = 'completed'; // Direct completion for now
|
||||
|
||||
// 2. Create Header
|
||||
$goodsReceipt = GoodsReceipt::create($data);
|
||||
|
||||
// 3. Process Items
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// Create GR Item
|
||||
$grItem = new GoodsReceiptItem([
|
||||
'product_id' => $itemData['product_id'],
|
||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||
'quantity_received' => $itemData['quantity_received'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
$goodsReceipt->items()->save($grItem);
|
||||
|
||||
// 4. Update Inventory
|
||||
$reason = match($goodsReceipt->type) {
|
||||
'standard' => '採購進貨',
|
||||
'miscellaneous' => '雜項入庫',
|
||||
'other' => '其他入庫',
|
||||
default => '進貨入庫',
|
||||
};
|
||||
|
||||
$this->inventoryService->createInventoryRecord([
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'product_id' => $grItem->product_id,
|
||||
'quantity' => $grItem->quantity_received,
|
||||
'unit_cost' => $grItem->unit_price,
|
||||
'batch_number' => $grItem->batch_number,
|
||||
'expiry_date' => $grItem->expiry_date,
|
||||
'reason' => $reason,
|
||||
'reference_type' => GoodsReceipt::class,
|
||||
'reference_id' => $goodsReceipt->id,
|
||||
'source_purchase_order_id' => $goodsReceipt->purchase_order_id,
|
||||
'arrival_date' => $goodsReceipt->received_date,
|
||||
]);
|
||||
|
||||
// 5. Update PO if linked and type is standard
|
||||
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) {
|
||||
$this->procurementService->updateReceivedQuantity(
|
||||
$grItem->purchase_order_item_id,
|
||||
$grItem->quantity_received
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $goodsReceipt;
|
||||
});
|
||||
}
|
||||
|
||||
private function generateCode(string $date)
|
||||
{
|
||||
// Format: GR-YYYYMMDD-NN
|
||||
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
|
||||
|
||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||
->orderBy('id', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($last) {
|
||||
$seq = intval(substr($last->code, -2)) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
248
app/Modules/Inventory/Services/InventoryReportService.php
Normal file
248
app/Modules/Inventory/Services/InventoryReportService.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\Product; // Use Inventory module's Product if available, or Core's? Usually Product is in Inventory/Models? No, let's check.
|
||||
// Checking Product model location... likely App\Modules\Product\Models\Product or App\Modules\Inventory\Models\Product.
|
||||
// From previous context: "products.create" permission suggests a Products module.
|
||||
// But stock query uses `products` table join.
|
||||
// Let's assume standard Laravel query builder or check existing models.
|
||||
// StockQueryController uses `InventoryService`.
|
||||
// I will use DB facade or InventoryTransaction model for aggregation.
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class InventoryReportService
|
||||
{
|
||||
/**
|
||||
* 取得庫存報表資料
|
||||
*
|
||||
* @param array $filters 篩選條件
|
||||
* @param int|null $perPage 每頁筆數
|
||||
* @return \Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Support\Collection
|
||||
*/
|
||||
public function getReportData(array $filters, ?int $perPage = 10)
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
$categoryId = $filters['category_id'] ?? null;
|
||||
$search = $filters['search'] ?? null;
|
||||
$sortBy = $filters['sort_by'] ?? 'product_code';
|
||||
$sortOrder = $filters['sort_order'] ?? 'asc';
|
||||
|
||||
// 若無任何篩選條件,直接回傳空資料
|
||||
if (!$dateFrom && !$dateTo && !$warehouseId && !$categoryId && !$search) {
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage ?: 10);
|
||||
}
|
||||
|
||||
// 定義時間欄位轉換 (UTC -> Asia/Taipei)
|
||||
// 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time
|
||||
$timeColumn = "inventory_transactions.actual_time";
|
||||
|
||||
// 建立查詢
|
||||
// 我們需要針對每個 品項 在選定區間內 進行彙總
|
||||
// 來源:inventory_transactions -> inventory -> product
|
||||
|
||||
$query = InventoryTransaction::query()
|
||||
->select([
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'categories.name as category_name',
|
||||
'products.id as product_id',
|
||||
// 進貨量:type 為 入庫, 手動入庫 (排除 調撥入庫)
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('入庫', '手動入庫') AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as inbound_qty"),
|
||||
// 出貨量:type 為 出庫 (排除 調撥出庫) (取絕對值)
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type IN ('出庫') AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as outbound_qty"),
|
||||
// 調撥入:type 為 調撥入庫
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type = '調撥入庫' AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as transfer_in_qty"),
|
||||
// 調撥出:type 為 調撥出庫 (取絕對值)
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type = '調撥出庫' AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as transfer_out_qty"),
|
||||
// 調整量:type 為 庫存調整, 手動編輯
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('庫存調整', '手動編輯') THEN inventory_transactions.quantity ELSE 0 END) as adjust_qty"),
|
||||
// 淨變動:總和 (包含所有類型:進貨、出貨、調整、調撥)
|
||||
DB::raw("SUM(inventory_transactions.quantity) as net_change"),
|
||||
])
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id');
|
||||
|
||||
// 日期篩選:資料庫儲存的是台北時間,直接用字串比對
|
||||
if ($dateFrom && $dateTo) {
|
||||
$query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [
|
||||
$dateFrom . ' 00:00:00',
|
||||
$dateTo . ' 23:59:59'
|
||||
]);
|
||||
} elseif ($dateFrom) {
|
||||
$query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']);
|
||||
} elseif ($dateTo) {
|
||||
$query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']);
|
||||
}
|
||||
|
||||
// 應用篩選
|
||||
if ($warehouseId) {
|
||||
$query->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
if ($categoryId) {
|
||||
$query->where('products.category_id', $categoryId);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.name', 'like', "%{$search}%")
|
||||
->orWhere('products.code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 分組
|
||||
$query->groupBy([
|
||||
'products.id',
|
||||
'products.code',
|
||||
'products.name',
|
||||
'categories.name'
|
||||
]);
|
||||
|
||||
// 動態排序
|
||||
$allowedSortFields = [
|
||||
'product_code' => 'products.code',
|
||||
'product_name' => 'products.name',
|
||||
'inbound_qty' => 'inbound_qty',
|
||||
'outbound_qty' => 'outbound_qty',
|
||||
'transfer_in_qty' => 'transfer_in_qty',
|
||||
'transfer_out_qty' => 'transfer_out_qty',
|
||||
'adjust_qty' => 'adjust_qty',
|
||||
'net_change' => 'net_change',
|
||||
];
|
||||
|
||||
$sortColumn = $allowedSortFields[$sortBy] ?? 'products.code';
|
||||
$query->orderBy($sortColumn, $sortOrder === 'desc' ? 'desc' : 'asc');
|
||||
|
||||
|
||||
if ($perPage) {
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得報表統計數據 (不分頁,針對篩選條件的全量統計)
|
||||
*/
|
||||
public function getSummary(array $filters)
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
$categoryId = $filters['category_id'] ?? null;
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
// 若無任何篩選條件,直接回傳零值
|
||||
if (!$dateFrom && !$dateTo && !$warehouseId && !$categoryId && !$search) {
|
||||
return (object)[
|
||||
'total_inbound' => 0,
|
||||
'total_outbound' => 0,
|
||||
'total_adjust' => 0,
|
||||
'total_net_change' => 0,
|
||||
];
|
||||
}
|
||||
// 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time
|
||||
$timeColumn = "inventory_transactions.actual_time";
|
||||
|
||||
$query = InventoryTransaction::query()
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id');
|
||||
|
||||
// 日期篩選:資料庫儲存的是台北時間,直接用字串比對
|
||||
if ($dateFrom && $dateTo) {
|
||||
$query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [
|
||||
$dateFrom . ' 00:00:00',
|
||||
$dateTo . ' 23:59:59'
|
||||
]);
|
||||
} elseif ($dateFrom) {
|
||||
$query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']);
|
||||
} elseif ($dateTo) {
|
||||
$query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']);
|
||||
}
|
||||
|
||||
if ($warehouseId) {
|
||||
$query->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
if ($categoryId) {
|
||||
$query->where('products.category_id', $categoryId);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.name', 'like', "%{$search}%")
|
||||
->orWhere('products.code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 直接聚合所有符合條件的交易
|
||||
return $query->select([
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('入庫', '手動入庫') AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as total_inbound"),
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type IN ('出庫') AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as total_outbound"),
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type = '調撥入庫' AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as total_transfer_in"),
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type = '調撥出庫' AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as total_transfer_out"),
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('庫存調整', '手動編輯') THEN inventory_transactions.quantity ELSE 0 END) as total_adjust"),
|
||||
DB::raw("SUM(inventory_transactions.quantity) as total_net_change"),
|
||||
])->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得特定商品的庫存異動明細
|
||||
*/
|
||||
public function getProductDetails($productId, array $filters, ?int $perPage = 20)
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
// 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time
|
||||
$timeColumn = "inventory_transactions.actual_time";
|
||||
|
||||
$query = InventoryTransaction::query()
|
||||
->select([
|
||||
'inventory_transactions.*',
|
||||
'inventories.warehouse_id',
|
||||
'inventories.batch_number as batch_no',
|
||||
'warehouses.name as warehouse_name',
|
||||
'users.name as user_name',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'units.name as unit_name'
|
||||
])
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->leftJoin('units', 'products.base_unit_id', '=', 'units.id')
|
||||
->leftJoin('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('users', 'inventory_transactions.user_id', '=', 'users.id')
|
||||
->where('products.id', $productId);
|
||||
|
||||
// 日期篩選:資料庫儲存的是台北時間,直接用字串比對
|
||||
if ($dateFrom && $dateTo) {
|
||||
$query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [
|
||||
$dateFrom . ' 00:00:00',
|
||||
$dateTo . ' 23:59:59'
|
||||
]);
|
||||
} elseif ($dateFrom) {
|
||||
$query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']);
|
||||
} elseif ($dateTo) {
|
||||
$query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']);
|
||||
}
|
||||
|
||||
if ($warehouseId) {
|
||||
$query->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
// 排序:最新的在最上面
|
||||
$query->orderBy('inventory_transactions.actual_time', 'desc')
|
||||
->orderBy('inventory_transactions.id', 'desc');
|
||||
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InventoryService implements InventoryServiceInterface
|
||||
@@ -17,7 +18,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getAllProducts()
|
||||
{
|
||||
return Product::with(['baseUnit'])->get();
|
||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getUnits()
|
||||
@@ -32,17 +33,17 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getProduct(int $id)
|
||||
{
|
||||
return Product::find($id);
|
||||
return Product::with(['baseUnit', 'largeUnit'])->find($id);
|
||||
}
|
||||
|
||||
public function getProductsByIds(array $ids)
|
||||
{
|
||||
return Product::whereIn('id', $ids)->get();
|
||||
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getProductsByName(string $name)
|
||||
{
|
||||
return Product::where('name', 'like', "%{$name}%")->get();
|
||||
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getWarehouse(int $id)
|
||||
@@ -59,13 +60,18 @@ class InventoryService implements InventoryServiceInterface
|
||||
return $stock >= $quantity;
|
||||
}
|
||||
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void
|
||||
{
|
||||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason) {
|
||||
$inventories = Inventory::where('product_id', $productId)
|
||||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) {
|
||||
$query = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->where('quantity', '>', 0)
|
||||
->orderBy('arrival_date', 'asc')
|
||||
->where('quantity', '>', 0);
|
||||
|
||||
if ($slot) {
|
||||
$query->where('location', $slot);
|
||||
}
|
||||
|
||||
$inventories = $query->orderBy('arrival_date', 'asc')
|
||||
->get();
|
||||
|
||||
$remainingToDecrease = $quantity;
|
||||
@@ -79,8 +85,36 @@ class InventoryService implements InventoryServiceInterface
|
||||
}
|
||||
|
||||
if ($remainingToDecrease > 0) {
|
||||
// 這裡可以選擇報錯或允許負庫存,目前為了嚴謹拋出異常
|
||||
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
||||
if ($force) {
|
||||
// Find any existing inventory record in this warehouse/slot to subtract from, or create one
|
||||
$query = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId);
|
||||
|
||||
if ($slot) {
|
||||
$query->where('location', $slot);
|
||||
}
|
||||
|
||||
$inventory = $query->first();
|
||||
|
||||
if (!$inventory) {
|
||||
$inventory = Inventory::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'product_id' => $productId,
|
||||
'location' => $slot,
|
||||
'quantity' => 0,
|
||||
'unit_cost' => 0,
|
||||
'total_value' => 0,
|
||||
'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(),
|
||||
'arrival_date' => now(),
|
||||
'origin_country' => 'TW',
|
||||
'quality_status' => 'normal',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason);
|
||||
} else {
|
||||
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -188,23 +222,398 @@ class InventoryService implements InventoryServiceInterface
|
||||
});
|
||||
}
|
||||
|
||||
public function getDashboardStats(): array
|
||||
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber)
|
||||
{
|
||||
// 庫存總表 join 安全庫存表,計算低庫存
|
||||
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
|
||||
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
|
||||
function ($join) {
|
||||
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
|
||||
->on('ss.product_id', '=', 'inv.product_id');
|
||||
})
|
||||
->whereRaw('inv.total_qty <= ss.safety_stock')
|
||||
return Inventory::where('warehouse_id', $warehouseId)
|
||||
->where('product_id', $productId)
|
||||
->where('batch_number', $batchNumber)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 即時庫存查詢:統計卡片 + 分頁明細
|
||||
*/
|
||||
public function getStockQueryData(array $filters = [], int $perPage = 10): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
// 基礎查詢
|
||||
$query = Inventory::query()
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('inventories.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('inventories.deleted_at')
|
||||
->select([
|
||||
'inventories.id',
|
||||
'inventories.warehouse_id',
|
||||
'inventories.product_id',
|
||||
'inventories.quantity',
|
||||
'inventories.batch_number',
|
||||
'inventories.expiry_date',
|
||||
'inventories.location',
|
||||
'inventories.quality_status',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'categories.name as category_name',
|
||||
'warehouses.name as warehouse_name',
|
||||
'ss.safety_stock',
|
||||
]);
|
||||
|
||||
// 篩選:倉庫
|
||||
if (!empty($filters['warehouse_id'])) {
|
||||
$query->where('inventories.warehouse_id', $filters['warehouse_id']);
|
||||
}
|
||||
|
||||
// 篩選:分類
|
||||
if (!empty($filters['category_id'])) {
|
||||
$query->where('products.category_id', $filters['category_id']);
|
||||
}
|
||||
|
||||
// 篩選:關鍵字(商品代碼或名稱)
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.code', 'like', "%{$search}%")
|
||||
->orWhere('products.name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 篩選:狀態 (改為對齊聚合統計的判斷標準)
|
||||
if (!empty($filters['status'])) {
|
||||
switch ($filters['status']) {
|
||||
case 'low_stock':
|
||||
$query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->join('warehouse_product_safety_stocks as ss2', function ($join) {
|
||||
$join->on('i2.warehouse_id', '=', 'ss2.warehouse_id')
|
||||
->on('i2.product_id', '=', 'ss2.product_id');
|
||||
})
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id', 'ss2.safety_stock')
|
||||
->havingRaw('SUM(i2.quantity) <= ss2.safety_stock');
|
||||
});
|
||||
break;
|
||||
case 'negative':
|
||||
$query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) < 0');
|
||||
});
|
||||
break;
|
||||
case 'expiring':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '>', $today)
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
break;
|
||||
case 'expired':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $today);
|
||||
break;
|
||||
case 'abnormal':
|
||||
// 只要該「倉庫-品項」對應的總庫存有低庫存、負庫存,或該批次已過期/即將過期
|
||||
$query->where(function ($q) use ($today, $expiryThreshold) {
|
||||
// 1. 低庫存或負庫存 (依聚合判斷)
|
||||
$q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i3.warehouse_id', 'i3.product_id')
|
||||
->from('inventories as i3')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) {
|
||||
$join->on('i3.warehouse_id', '=', 'ss3.warehouse_id')
|
||||
->on('i3.product_id', '=', 'ss3.product_id');
|
||||
})
|
||||
->whereNull('i3.deleted_at')
|
||||
->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock')
|
||||
->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)');
|
||||
})
|
||||
// 2. 或該批次效期異常
|
||||
->orWhere(function ($q_batch) use ($expiryThreshold) {
|
||||
$q_batch->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
$sortBy = $filters['sort_by'] ?? 'products.code';
|
||||
$sortOrder = $filters['sort_order'] ?? 'asc';
|
||||
$allowedSorts = ['products.code', 'products.name', 'warehouses.name', 'inventories.quantity', 'inventories.expiry_date'];
|
||||
if (in_array($sortBy, $allowedSorts)) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
$query->orderBy('products.code', 'asc');
|
||||
}
|
||||
|
||||
// 統計卡片(預設無篩選條件下的全域統計,改為明細筆數計數以對齊顯示)
|
||||
// 1. 庫存明細總數
|
||||
$totalItems = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// 2. 低庫存明細數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入
|
||||
$lowStockCount = DB::table('inventories as i')
|
||||
->join('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('i.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('i.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) <= (SELECT safety_stock FROM warehouse_product_safety_stocks WHERE warehouse_id = i2.warehouse_id AND product_id = i2.product_id LIMIT 1)');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 3. 負庫存明細數
|
||||
$negativeCount = DB::table('inventories as i')
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) < 0');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 4. 即將過期明細數 (必須排除已過期)
|
||||
$expiringCount = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '>', $today)
|
||||
->where('expiry_date', '<=', $expiryThreshold)
|
||||
->count();
|
||||
|
||||
// 分頁
|
||||
$paginated = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// 為每筆紀錄附加最後入庫/出庫時間 + 狀態
|
||||
$items = collect($paginated->items())->map(function ($item) use ($today, $expiryThreshold) {
|
||||
$lastIn = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id)
|
||||
->where('type', '入庫')
|
||||
->orderByDesc('actual_time')
|
||||
->value('actual_time');
|
||||
|
||||
$lastOut = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id)
|
||||
->where('type', '出庫')
|
||||
->orderByDesc('actual_time')
|
||||
->value('actual_time');
|
||||
|
||||
// 計算狀態
|
||||
$statuses = [];
|
||||
if ($item->quantity < 0) {
|
||||
$statuses[] = 'negative';
|
||||
}
|
||||
if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) {
|
||||
$statuses[] = 'low_stock';
|
||||
}
|
||||
if ($item->expiry_date) {
|
||||
if ($item->expiry_date <= $today) {
|
||||
$statuses[] = 'expired';
|
||||
} elseif ($item->expiry_date <= $expiryThreshold) {
|
||||
$statuses[] = 'expiring';
|
||||
}
|
||||
}
|
||||
if (empty($statuses)) {
|
||||
$statuses[] = 'normal';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_code' => $item->product_code,
|
||||
'product_name' => $item->product_name,
|
||||
'category_name' => $item->category_name,
|
||||
'warehouse_name' => $item->warehouse_name,
|
||||
'batch_number' => $item->batch_number,
|
||||
'quantity' => $item->quantity,
|
||||
'safety_stock' => $item->safety_stock,
|
||||
'expiry_date' => $item->expiry_date ? \Carbon\Carbon::parse($item->expiry_date)->toDateString() : null,
|
||||
'location' => $item->location,
|
||||
'quality_status' => $item->quality_status ?? null,
|
||||
'last_inbound' => $lastIn ? \Carbon\Carbon::parse($lastIn)->toDateString() : null,
|
||||
'last_outbound' => $lastOut ? \Carbon\Carbon::parse($lastOut)->toDateString() : null,
|
||||
'statuses' => $statuses,
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'productsCount' => Product::count(),
|
||||
'warehousesCount' => Warehouse::count(),
|
||||
'lowStockCount' => $lowStockCount,
|
||||
'totalInventoryQuantity' => Inventory::sum('quantity'),
|
||||
'summary' => [
|
||||
'totalItems' => $totalItems,
|
||||
'lowStockCount' => $lowStockCount,
|
||||
'negativeCount' => $negativeCount,
|
||||
'expiringCount' => $expiringCount,
|
||||
],
|
||||
'data' => $items->toArray(),
|
||||
'pagination' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'links' => $paginated->linkCollection()->toArray(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getDashboardStats(): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
// 1. 庫存品項數 (明細總數)
|
||||
$totalItems = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// 2. 低庫存 (明細計數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入)
|
||||
$lowStockCount = DB::table('inventories as i')
|
||||
->join('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('i.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('i.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) <= (SELECT safety_stock FROM warehouse_product_safety_stocks WHERE warehouse_id = i2.warehouse_id AND product_id = i2.product_id LIMIT 1)');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 3. 負庫存 (明細計數)
|
||||
$negativeCount = DB::table('inventories as i')
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) < 0');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 4. 即將過期 (明細計數)
|
||||
$expiringCount = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '>', $today) // 確保不過期 (getStockQueryData 沒加這個但這裡加上以防與 expired 混淆? 不,stock query 是 > today && <= threshold)
|
||||
->where('expiry_date', '<=', $expiryThreshold)
|
||||
->count();
|
||||
|
||||
// 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉)
|
||||
$abnormalItems = Inventory::query()
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('inventories.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('inventories.deleted_at')
|
||||
->where(function ($q) use ($today, $expiryThreshold) {
|
||||
// 1. 屬於低庫存或負庫存品項的批次
|
||||
$q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i3.warehouse_id', 'i3.product_id')
|
||||
->from('inventories as i3')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) {
|
||||
$join->on('i3.warehouse_id', '=', 'ss3.warehouse_id')
|
||||
->on('i3.product_id', '=', 'ss3.product_id');
|
||||
})
|
||||
->whereNull('i3.deleted_at')
|
||||
->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock')
|
||||
->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)');
|
||||
})
|
||||
// 2. 或單一批次效期異常
|
||||
->orWhere(function ($q2) use ($expiryThreshold) {
|
||||
$q2->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
});
|
||||
})
|
||||
->select([
|
||||
'inventories.id',
|
||||
'inventories.quantity',
|
||||
'inventories.expiry_date',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'warehouses.name as warehouse_name',
|
||||
'ss.safety_stock',
|
||||
])
|
||||
->orderBy('inventories.id', 'desc')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($item) use ($today, $expiryThreshold) {
|
||||
$statuses = [];
|
||||
if ($item->quantity < 0) {
|
||||
$statuses[] = 'negative';
|
||||
}
|
||||
if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) {
|
||||
$statuses[] = 'low_stock';
|
||||
}
|
||||
if ($item->expiry_date) {
|
||||
if ($item->expiry_date <= $today) {
|
||||
$statuses[] = 'expired';
|
||||
} elseif ($item->expiry_date <= $expiryThreshold) {
|
||||
$statuses[] = 'expiring';
|
||||
}
|
||||
}
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_code' => $item->product_code,
|
||||
'product_name' => $item->product_name,
|
||||
'warehouse_name' => $item->warehouse_name,
|
||||
'quantity' => $item->quantity,
|
||||
'safety_stock' => $item->safety_stock,
|
||||
'expiry_date' => $item->expiry_date,
|
||||
'statuses' => $statuses,
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'productsCount' => $totalItems,
|
||||
'warehousesCount' => Warehouse::count(),
|
||||
'lowStockCount' => $lowStockCount,
|
||||
'negativeCount' => $negativeCount,
|
||||
'expiringCount' => $expiringCount,
|
||||
'totalInventoryQuantity' => Inventory::sum('quantity'),
|
||||
'totalInventoryValue' => Inventory::sum('total_value'),
|
||||
'pendingTransferCount' => InventoryTransferOrder::whereIn('status', ['draft', 'dispatched'])->count(), // 新增:待處理調撥單
|
||||
'abnormalItems' => $abnormalItems,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
|
||||
*
|
||||
* @param string $warehouseName
|
||||
* @return Warehouse
|
||||
*/
|
||||
public function findOrCreateWarehouseByName(string $warehouseName)
|
||||
{
|
||||
// 1. 優先查找名稱完全匹配的倉庫(不限類型)
|
||||
$warehouse = Warehouse::where('name', $warehouseName)
|
||||
->first();
|
||||
|
||||
if ($warehouse) {
|
||||
return $warehouse;
|
||||
}
|
||||
|
||||
// 2. 若找不到對應倉庫,則統一進入「整合銷售倉」(類型:retail)
|
||||
return Warehouse::firstOrCreate(
|
||||
['name' => '整合銷售倉'],
|
||||
[
|
||||
'code' => 'INT-RETAIL-001',
|
||||
'type' => 'retail',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
113
app/Modules/Inventory/Services/ProductService.php
Normal file
113
app/Modules/Inventory/Services/ProductService.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProductService implements ProductServiceInterface
|
||||
{
|
||||
/**
|
||||
* Upsert product from external POS source.
|
||||
*
|
||||
* @param array $data
|
||||
* @return Product
|
||||
*/
|
||||
public function upsertFromPos(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$externalId = $data['external_pos_id'] ?? null;
|
||||
|
||||
if (!$externalId) {
|
||||
throw new \Exception("External POS ID is required for syncing.");
|
||||
}
|
||||
|
||||
// Try to find by external_pos_id
|
||||
$product = Product::where('external_pos_id', $externalId)->first();
|
||||
|
||||
if (!$product) {
|
||||
// If not found, create new
|
||||
// Optional: Check SKU conflict if needed, but for now trust POS ID
|
||||
$product = new Product();
|
||||
$product->external_pos_id = $externalId;
|
||||
}
|
||||
|
||||
// Map allowed fields
|
||||
$product->name = $data['name'];
|
||||
$product->barcode = $data['barcode'] ?? $product->barcode;
|
||||
$product->price = $data['price'] ?? 0;
|
||||
|
||||
// Map newly added extended fields
|
||||
if (isset($data['brand'])) $product->brand = $data['brand'];
|
||||
if (isset($data['specification'])) $product->specification = $data['specification'];
|
||||
if (isset($data['cost_price'])) $product->cost_price = $data['cost_price'];
|
||||
if (isset($data['member_price'])) $product->member_price = $data['member_price'];
|
||||
if (isset($data['wholesale_price'])) $product->wholesale_price = $data['wholesale_price'];
|
||||
|
||||
// Generate Code if missing (use code or external_id)
|
||||
if (empty($product->code)) {
|
||||
$product->code = $data['code'] ?? $product->external_pos_id;
|
||||
}
|
||||
|
||||
// Handle Category — 每次同步都更新(若有傳入)
|
||||
if (!empty($data['category']) || empty($product->category_id)) {
|
||||
$categoryName = $data['category'] ?? '未分類';
|
||||
$category = Category::firstOrCreate(
|
||||
['name' => $categoryName],
|
||||
['code' => 'CAT-' . strtoupper(bin2hex(random_bytes(4)))]
|
||||
);
|
||||
$product->category_id = $category->id;
|
||||
}
|
||||
|
||||
// Handle Base Unit — 每次同步都更新(若有傳入)
|
||||
if (!empty($data['unit']) || empty($product->base_unit_id)) {
|
||||
$unitName = $data['unit'] ?? '個';
|
||||
$unit = Unit::firstOrCreate(['name' => $unitName]);
|
||||
$product->base_unit_id = $unit->id;
|
||||
}
|
||||
|
||||
$product->is_active = $data['is_active'] ?? true;
|
||||
|
||||
$product->save();
|
||||
|
||||
return $product;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 透過外部 POS ID 查找產品。
|
||||
*
|
||||
* @param string $externalPosId
|
||||
* @return Product|null
|
||||
*/
|
||||
public function findByExternalPosId(string $externalPosId)
|
||||
{
|
||||
return Product::where('external_pos_id', $externalPosId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 透過多個外部 POS ID 查找產品。
|
||||
*
|
||||
* @param array $externalPosIds
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByExternalPosIds(array $externalPosIds)
|
||||
{
|
||||
return Product::whereIn('external_pos_id', $externalPosIds)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||
*
|
||||
* @param array $codes
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByCodes(array $codes)
|
||||
{
|
||||
return Product::whereIn('code', $codes)->get();
|
||||
}
|
||||
}
|
||||
247
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
247
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\StoreRequisition;
|
||||
use App\Modules\Inventory\Models\StoreRequisitionItem;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Notifications\StoreRequisitionNotification;
|
||||
use App\Modules\Core\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StoreRequisitionService
|
||||
{
|
||||
protected TransferService $transferService;
|
||||
|
||||
public function __construct(TransferService $transferService)
|
||||
{
|
||||
$this->transferService = $transferService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立叫貨單(含明細)
|
||||
*/
|
||||
public function create(array $data, array $items, int $userId): StoreRequisition
|
||||
{
|
||||
return DB::transaction(function () use ($data, $items, $userId) {
|
||||
$requisition = StoreRequisition::create([
|
||||
'store_warehouse_id' => $data['store_warehouse_id'],
|
||||
'status' => 'draft',
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$requisition->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $requisition->load('items');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新叫貨單(僅限 draft / rejected 狀態)
|
||||
*/
|
||||
public function update(StoreRequisition $requisition, array $data, array $items): StoreRequisition
|
||||
{
|
||||
if (!in_array($requisition->status, ['draft', 'rejected'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能編輯草稿或被駁回的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($requisition, $data, $items) {
|
||||
$requisition->update([
|
||||
'store_warehouse_id' => $data['store_warehouse_id'],
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'reject_reason' => null, // 清除駁回原因
|
||||
]);
|
||||
|
||||
// 重建明細
|
||||
$requisition->items()->delete();
|
||||
foreach ($items as $item) {
|
||||
$requisition->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $requisition->load('items');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交審核(draft → pending)
|
||||
*/
|
||||
public function submit(StoreRequisition $requisition, int $userId): StoreRequisition
|
||||
{
|
||||
if ($requisition->status !== 'draft' && $requisition->status !== 'rejected') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能提交草稿或被駁回的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($requisition->items()->count() === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => '叫貨單必須至少有一項商品',
|
||||
]);
|
||||
}
|
||||
|
||||
$requisition->update([
|
||||
'status' => 'pending',
|
||||
'submitted_at' => now(),
|
||||
'reject_reason' => null,
|
||||
]);
|
||||
|
||||
// 通知有審核權限的使用者
|
||||
$this->notifyApprovers($requisition, 'submitted', $userId);
|
||||
|
||||
return $requisition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核准叫貨單(pending → approved),選擇供貨倉庫並自動產生調撥單
|
||||
*/
|
||||
public function approve(StoreRequisition $requisition, array $data, int $userId): StoreRequisition
|
||||
{
|
||||
if ($requisition->status !== 'pending') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能核准待審核的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($requisition, $data, $userId) {
|
||||
// 更新核准數量
|
||||
if (isset($data['items'])) {
|
||||
foreach ($data['items'] as $itemData) {
|
||||
StoreRequisitionItem::where('id', $itemData['id'])
|
||||
->where('store_requisition_id', $requisition->id)
|
||||
->update(['approved_qty' => $itemData['approved_qty']]);
|
||||
}
|
||||
}
|
||||
|
||||
// 查詢供貨倉庫是否有預設在途倉
|
||||
$supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($data['supply_warehouse_id']);
|
||||
$defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id;
|
||||
|
||||
// 產生調撥單(供貨倉庫 → 門市倉庫)
|
||||
$transferOrder = $this->transferService->createOrder(
|
||||
fromWarehouseId: $data['supply_warehouse_id'],
|
||||
toWarehouseId: $requisition->store_warehouse_id,
|
||||
remarks: "由叫貨單 {$requisition->doc_no} 自動產生",
|
||||
userId: $userId,
|
||||
transitWarehouseId: $defaultTransitId,
|
||||
);
|
||||
|
||||
// 將核准的明細寫入調撥單
|
||||
$requisition->load('items');
|
||||
$transferItems = [];
|
||||
foreach ($requisition->items as $item) {
|
||||
$qty = $item->approved_qty ?? $item->requested_qty;
|
||||
if ($qty > 0) {
|
||||
$transferItems[] = [
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $qty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($transferItems)) {
|
||||
$this->transferService->updateItems($transferOrder, $transferItems);
|
||||
}
|
||||
|
||||
// 更新叫貨單狀態
|
||||
$requisition->update([
|
||||
'status' => 'approved',
|
||||
'supply_warehouse_id' => $data['supply_warehouse_id'],
|
||||
'approved_by' => $userId,
|
||||
'approved_at' => now(),
|
||||
'transfer_order_id' => $transferOrder->id,
|
||||
]);
|
||||
|
||||
// 通知申請人
|
||||
$this->notifyCreator($requisition, 'approved', $userId);
|
||||
|
||||
return $requisition->load(['items', 'transferOrder']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 駁回叫貨單(pending → rejected)
|
||||
*/
|
||||
public function reject(StoreRequisition $requisition, string $reason, int $userId): StoreRequisition
|
||||
{
|
||||
if ($requisition->status !== 'pending') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能駁回待審核的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
$requisition->update([
|
||||
'status' => 'rejected',
|
||||
'reject_reason' => $reason,
|
||||
'approved_by' => $userId,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
// 通知申請人
|
||||
$this->notifyCreator($requisition, 'rejected', $userId);
|
||||
|
||||
return $requisition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消叫貨單
|
||||
*/
|
||||
public function cancel(StoreRequisition $requisition): StoreRequisition
|
||||
{
|
||||
if (!in_array($requisition->status, ['draft', 'pending'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能取消草稿或待審核的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
$requisition->update(['status' => 'cancelled']);
|
||||
|
||||
return $requisition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知有審核權限的使用者
|
||||
*/
|
||||
protected function notifyApprovers(StoreRequisition $requisition, string $action, int $actorId): void
|
||||
{
|
||||
$actor = User::find($actorId);
|
||||
$actorName = $actor?->name ?? 'System';
|
||||
|
||||
// 找出有 store_requisitions.approve 權限的使用者
|
||||
$approvers = User::permission('store_requisitions.approve')->get();
|
||||
|
||||
foreach ($approvers as $approver) {
|
||||
if ($approver->id !== $actorId) {
|
||||
$approver->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知叫貨單申請人
|
||||
*/
|
||||
protected function notifyCreator(StoreRequisition $requisition, string $action, int $actorId): void
|
||||
{
|
||||
$actor = User::find($actorId);
|
||||
$actorName = $actor?->name ?? 'System';
|
||||
|
||||
$creator = User::find($requisition->created_by);
|
||||
if ($creator && $creator->id !== $actorId) {
|
||||
$creator->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
|
||||
}
|
||||
}
|
||||
}
|
||||
354
app/Modules/Inventory/Services/TransferService.php
Normal file
354
app/Modules/Inventory/Services/TransferService.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
/**
|
||||
* 建立調撥單草稿
|
||||
*/
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder
|
||||
{
|
||||
// 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定)
|
||||
if (is_null($transitWarehouseId)) {
|
||||
$fromWarehouse = Warehouse::find($fromWarehouseId);
|
||||
if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) {
|
||||
$transitWarehouseId = $fromWarehouse->default_transit_warehouse_id;
|
||||
}
|
||||
}
|
||||
|
||||
return InventoryTransferOrder::create([
|
||||
'from_warehouse_id' => $fromWarehouseId,
|
||||
'to_warehouse_id' => $toWarehouseId,
|
||||
'transit_warehouse_id' => $transitWarehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
|
||||
*/
|
||||
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
|
||||
{
|
||||
return DB::transaction(function () use ($order, $itemsData) {
|
||||
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
|
||||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||||
return [$key => $item];
|
||||
});
|
||||
|
||||
$diff = [
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
$order->items()->delete();
|
||||
$newItemsKeys = [];
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
||||
$newItemsKeys[] = $key;
|
||||
|
||||
$item = $order->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'position' => $data['position'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
$item->load('product');
|
||||
|
||||
if ($oldItemsMap->has($key)) {
|
||||
$oldItem = $oldItemsMap->get($key);
|
||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
||||
$oldItem->notes !== ($data['notes'] ?? null) ||
|
||||
$oldItem->position !== ($data['position'] ?? null)) {
|
||||
|
||||
$diff['updated'][] = [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->quantity,
|
||||
'position' => $oldItem->position,
|
||||
'notes' => $oldItem->notes,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => (float)$data['quantity'],
|
||||
'position' => $item->position,
|
||||
'notes' => $item->notes,
|
||||
]
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$diff['updated'][] = [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
'quantity' => 0,
|
||||
'notes' => null,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => (float)$item->quantity,
|
||||
'notes' => $item->notes,
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($oldItemsMap as $key => $oldItem) {
|
||||
if (!in_array($key, $newItemsKeys)) {
|
||||
$diff['removed'][] = [
|
||||
'product_name' => $oldItem->product->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->quantity,
|
||||
'notes' => $oldItem->notes,
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
||||
if ($hasChanged) {
|
||||
$order->activityProperties['items_diff'] = $diff;
|
||||
}
|
||||
|
||||
return $hasChanged;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
||||
*
|
||||
* 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched
|
||||
* 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯)
|
||||
*/
|
||||
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
$order->load('items.product');
|
||||
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$fromWarehouse = $order->fromWarehouse;
|
||||
$hasTransit = !empty($order->transit_warehouse_id);
|
||||
|
||||
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
||||
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
||||
|
||||
$outType = '調撥出庫';
|
||||
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 處理來源倉 (扣除)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||
$availableQty = $sourceInventory->quantity ?? 0;
|
||||
$shortageQty = $item->quantity - $availableQty;
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足。現有庫存:{$availableQty},尚欠:{$shortageQty}。"],
|
||||
]);
|
||||
}
|
||||
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||
|
||||
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
$sourceInventory->save();
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
$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,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($hasTransit) {
|
||||
$order->status = 'dispatched';
|
||||
$order->dispatched_at = now();
|
||||
$order->dispatched_by = $userId;
|
||||
} else {
|
||||
$order->status = 'completed';
|
||||
$order->posted_at = now();
|
||||
$order->posted_by = $userId;
|
||||
}
|
||||
$order->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
||||
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
|
||||
*/
|
||||
public function receive(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'dispatched') {
|
||||
throw new \Exception('僅能對已出貨的調撥單進行收貨確認');
|
||||
}
|
||||
|
||||
if (empty($order->transit_warehouse_id)) {
|
||||
throw new \Exception('此調撥單未設定在途倉庫');
|
||||
}
|
||||
|
||||
$order->load('items.product');
|
||||
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$transitWarehouse = $order->transitWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 在途倉扣除
|
||||
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
||||
$availableQty = $transitInventory->quantity ?? 0;
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}。"],
|
||||
]);
|
||||
}
|
||||
|
||||
$oldTransitQty = $transitInventory->quantity;
|
||||
$newTransitQty = $oldTransitQty - $item->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,
|
||||
]);
|
||||
|
||||
// 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,
|
||||
]
|
||||
);
|
||||
|
||||
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' => '調撥入庫',
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$order->status = 'completed';
|
||||
$order->posted_at = now();
|
||||
$order->posted_by = $userId;
|
||||
$order->received_at = now();
|
||||
$order->received_by = $userId;
|
||||
$order->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作廢 (Void) - 僅限草稿狀態
|
||||
*/
|
||||
public function void(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$order->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
247
app/Modules/Inventory/Services/TurnoverService.php
Normal file
247
app/Modules/Inventory/Services/TurnoverService.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TurnoverService
|
||||
{
|
||||
/**
|
||||
* Get inventory turnover analysis data
|
||||
*/
|
||||
public function getAnalysisData(array $filters, int $perPage = 20)
|
||||
{
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
$categoryId = $filters['category_id'] ?? null;
|
||||
$search = $filters['search'] ?? null;
|
||||
$statusFilter = $filters['status'] ?? null; // 'dead', 'slow', 'normal'
|
||||
|
||||
// Base query for products with their current inventory sum
|
||||
$query = Product::query()
|
||||
->select([
|
||||
'products.id',
|
||||
'products.code',
|
||||
'products.name',
|
||||
'categories.name as category_name',
|
||||
'products.cost_price', // Assuming cost_price exists for value calculation
|
||||
])
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
|
||||
->leftJoin('inventories', 'products.id', '=', 'inventories.product_id')
|
||||
->groupBy(['products.id', 'products.code', 'products.name', 'categories.name', 'products.cost_price']);
|
||||
|
||||
// Filter by Warehouse (Current Inventory)
|
||||
if ($warehouseId) {
|
||||
$query->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
// Filter by Category
|
||||
if ($categoryId) {
|
||||
$query->where('products.category_id', $categoryId);
|
||||
}
|
||||
|
||||
// Filter by Search
|
||||
if ($search) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('products.name', 'like', "%{$search}%")
|
||||
->orWhere('products.code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Add Aggregated Columns
|
||||
|
||||
// 1. Current Inventory Quantity
|
||||
$query->addSelect(DB::raw('COALESCE(SUM(inventories.quantity), 0) as current_stock'));
|
||||
|
||||
// 2. Sales in last 30 days (Outbound)
|
||||
// We need a subquery or join for this to be efficient, or we use a separate query and map.
|
||||
// Given potentially large data, subquery per row might be slow, but for pagination it's okay-ish.
|
||||
// Better approach: Join with a subquery of aggregated transactions.
|
||||
|
||||
$thirtyDaysAgo = Carbon::now()->subDays(30);
|
||||
|
||||
// Subquery for 30-day sales
|
||||
$salesSubquery = InventoryTransaction::query()
|
||||
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
|
||||
->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
|
||||
->groupBy('inventories.product_id');
|
||||
|
||||
if ($warehouseId) {
|
||||
$salesSubquery->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
$query->leftJoinSub($salesSubquery, 'sales_30d', function ($join) {
|
||||
$join->on('products.id', '=', 'sales_30d.product_id');
|
||||
});
|
||||
$query->addSelect(DB::raw('COALESCE(sales_30d.sales_qty_30d, 0) as sales_30d'));
|
||||
|
||||
// 3. Last Sale Date
|
||||
// Use max actual_time from outbound transactions
|
||||
$lastSaleSubquery = InventoryTransaction::query()
|
||||
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->where('inventory_transactions.type', '出庫')
|
||||
->groupBy('inventories.product_id');
|
||||
|
||||
if ($warehouseId) {
|
||||
$lastSaleSubquery->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
$query->leftJoinSub($lastSaleSubquery, 'last_sales', function ($join) {
|
||||
$join->on('products.id', '=', 'last_sales.product_id');
|
||||
});
|
||||
$query->addSelect('last_sales.last_sale_date');
|
||||
|
||||
// Apply Status Filter (Dead Stock etc) requires having clauses or wrapper query.
|
||||
// Dead Stock: stock > 0 AND (last_sale_date < 90 days ago OR last_sale_date IS NULL)
|
||||
// Slow Moving: turnover days > X?
|
||||
|
||||
// Let's modify query to handle ordering and filtering on calculated fields if possible.
|
||||
// For simplicity in Laravel, we might fetch and transform, but pagination breaks.
|
||||
// We'll use HAVING for status filtering if needed.
|
||||
|
||||
// Order by
|
||||
$sortBy = $filters['sort_by'] ?? 'turnover_days'; // Default sort
|
||||
$sortOrder = $filters['sort_order'] ?? 'desc';
|
||||
|
||||
// Turnover Days Calculation in SQL: (stock / (sales_30d / 30)) => (stock * 30) / sales_30d
|
||||
// Handle division by zero: if sales_30d is 0, turnover is 'Inf' (or very high number like 9999)
|
||||
$turnoverDaysSql = "CASE WHEN COALESCE(sales_30d.sales_qty_30d, 0) > 0
|
||||
THEN (COALESCE(SUM(inventories.quantity), 0) * 30) / sales_30d.sales_qty_30d
|
||||
ELSE 9999 END";
|
||||
|
||||
$query->addSelect(DB::raw("$turnoverDaysSql as turnover_days"));
|
||||
|
||||
// Only show items with stock > 0 ? User might want to see out of stock items too?
|
||||
// Usually analysis focuses on what IS in stock. But Dead Stock needs items with stock.
|
||||
// Stock-out analysis needs items with 0 stock.
|
||||
// Let's filter stock > 0 by default for "Turnover Analysis".
|
||||
// $query->havingRaw('current_stock > 0');
|
||||
// Wait, better to let user filter?
|
||||
// For dead stock, definitive IS stock > 0.
|
||||
|
||||
if ($statusFilter === 'dead') {
|
||||
$ninetyDaysAgo = Carbon::now()->subDays(90);
|
||||
$query->havingRaw("current_stock > 0 AND (last_sale_date < ? OR last_sale_date IS NULL)", [$ninetyDaysAgo]);
|
||||
}
|
||||
|
||||
// Apply Sorting
|
||||
if ($sortBy === 'turnover_days') {
|
||||
$query->orderByRaw("$turnoverDaysSql $sortOrder");
|
||||
} else if (in_array($sortBy, ['current_stock', 'sales_30d', 'last_sale_date'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
$query->orderBy('products.code', 'asc');
|
||||
}
|
||||
|
||||
return $query->paginate($perPage)->withQueryString()->through(function($item) {
|
||||
// Post-processing for display
|
||||
$item->turnover_days_display = $item->turnover_days >= 9999 ? '∞' : number_format($item->turnover_days, 1);
|
||||
|
||||
// Determine Status Label
|
||||
$lastSale = $item->last_sale_date ? Carbon::parse($item->last_sale_date) : null;
|
||||
$daysSinceSale = $lastSale ? $lastSale->diffInDays(Carbon::now()) : 9999;
|
||||
|
||||
if ($item->current_stock > 0 && $daysSinceSale > 90) {
|
||||
$item->status = 'dead'; // 滯銷
|
||||
$item->status_label = '滯銷';
|
||||
} elseif ($item->current_stock > 0 && $item->turnover_days > 60) {
|
||||
$item->status = 'slow'; // 週轉慢
|
||||
$item->status_label = '週轉慢';
|
||||
} elseif ($item->current_stock == 0) {
|
||||
$item->status = 'out_of_stock';
|
||||
$item->status_label = '缺貨';
|
||||
} else {
|
||||
$item->status = 'normal';
|
||||
$item->status_label = '正常';
|
||||
}
|
||||
|
||||
return $item;
|
||||
});
|
||||
}
|
||||
|
||||
public function getKPIs(array $filters)
|
||||
{
|
||||
// Calculates aggregate KPIs
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
$categoryId = $filters['category_id'] ?? null;
|
||||
|
||||
// Helper to build base inv query
|
||||
$buildInvQuery = function() use ($warehouseId, $categoryId) {
|
||||
$q = DB::table('inventories')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->where('inventories.quantity', '>', 0);
|
||||
if ($warehouseId) $q->where('inventories.warehouse_id', $warehouseId);
|
||||
if ($categoryId) $q->where('products.category_id', $categoryId);
|
||||
return $q;
|
||||
};
|
||||
|
||||
// 1. Total Inventory Value (Cost)
|
||||
$totalValue = (clone $buildInvQuery())
|
||||
->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)'));
|
||||
|
||||
// 2. Dead Stock Value (No sale in 90 days)
|
||||
// Need last sale date for each product-location or just product?
|
||||
// Assuming dead stock is product-level logic for simplicity.
|
||||
|
||||
$ninetyDaysAgo = Carbon::now()->subDays(90);
|
||||
|
||||
// Get IDs of products sold in last 90 days
|
||||
$soldProductIds = InventoryTransaction::query()
|
||||
->where('type', '出庫')
|
||||
->where('actual_time', '>=', $ninetyDaysAgo)
|
||||
->distinct()
|
||||
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
|
||||
// We need product_id.
|
||||
->map(function($id) {
|
||||
return DB::table('inventories')->where('id', $id)->value('product_id');
|
||||
})
|
||||
->filter()
|
||||
->unique()
|
||||
->toArray();
|
||||
// Optimization: Use join in subquery
|
||||
|
||||
$soldProductIdsQuery = DB::table('inventory_transactions')
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->where('inventory_transactions.type', '出庫')
|
||||
->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
|
||||
->select('inventories.product_id')
|
||||
->distinct();
|
||||
|
||||
$deadStockQuery = (clone $buildInvQuery())
|
||||
->whereNotIn('products.id', $soldProductIdsQuery);
|
||||
|
||||
$deadStockValue = $deadStockQuery->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)'));
|
||||
$deadStockCount = $deadStockQuery->count('products.id'); // Count of inventory records (batches) or products?
|
||||
// Let's count distinct products
|
||||
$deadStockProductCount = $deadStockQuery->distinct('products.id')->count('products.id');
|
||||
|
||||
// 3. Average Turnover Days (Company wide)
|
||||
// Formula: (Avg Inventory / COGS) * 365 ?
|
||||
// Simplified: (Total Stock / Total Sales 30d) * 30
|
||||
|
||||
$totalStock = (clone $buildInvQuery())->sum('inventories.quantity');
|
||||
|
||||
$totalSales30d = DB::table('inventory_transactions')
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->where('inventory_transactions.type', '出庫')
|
||||
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays(30))
|
||||
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
|
||||
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
|
||||
->sum(DB::raw('ABS(inventory_transactions.quantity)'));
|
||||
|
||||
$avgTurnoverDays = $totalSales30d > 0 ? ($totalStock * 30) / $totalSales30d : 0;
|
||||
|
||||
return [
|
||||
'total_stock_value' => $totalValue,
|
||||
'dead_stock_value' => $deadStockValue,
|
||||
'dead_stock_count' => $deadStockProductCount,
|
||||
'avg_turnover_days' => round($avgTurnoverDays, 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,52 @@ interface ProcurementServiceInterface
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardStats(): array;
|
||||
|
||||
/**
|
||||
* Update received quantity for a PO item.
|
||||
*
|
||||
* @param int $poItemId
|
||||
* @param float $quantity
|
||||
* @return void
|
||||
*/
|
||||
public function updateReceivedQuantity(int $poItemId, float $quantity): void;
|
||||
|
||||
/**
|
||||
* Search pending or partial purchase orders.
|
||||
*
|
||||
* @param string $query
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchPendingPurchaseOrders(string $query): Collection;
|
||||
|
||||
/**
|
||||
* Search vendors by name or code.
|
||||
*
|
||||
* @param string $query
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchVendors(string $query): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有待進貨的採購單列表(不需搜尋條件)。
|
||||
* 用於進貨單頁面直接顯示可選擇的採購單。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPendingPurchaseOrders(): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有廠商列表。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllVendors(): Collection;
|
||||
|
||||
/**
|
||||
* Get vendors by multiple IDs.
|
||||
*
|
||||
* @param array $ids
|
||||
* @return Collection
|
||||
*/
|
||||
public function getVendorsByIds(array $ids): Collection;
|
||||
}
|
||||
|
||||
@@ -187,21 +187,22 @@ class PurchaseOrderController extends Controller
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// 生成單號:YYYYMMDD001
|
||||
// 生成單號:PO-YYYYMMDD-01
|
||||
$today = now()->format('Ymd');
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
|
||||
$prefix = 'PO-' . $today . '-';
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
||||
->lockForUpdate() // 鎖定以避免並發衝突
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastOrder) {
|
||||
// 取得最後 3 碼序號並加 1
|
||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
||||
// 取得最後 2 碼序號並加 1
|
||||
$lastSequence = intval(substr($lastOrder->code, -2));
|
||||
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$sequence = '001';
|
||||
$sequence = '01';
|
||||
}
|
||||
$code = $today . $sequence;
|
||||
$code = $prefix . $sequence;
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
@@ -419,7 +420,7 @@ class PurchaseOrderController extends Controller
|
||||
'order_date' => 'required|date', // 新增驗證
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
||||
'status' => 'required|string|in:draft,pending,approved,partial,completed,closed,cancelled',
|
||||
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||
'invoice_date' => 'nullable|date',
|
||||
'invoice_amount' => 'nullable|numeric|min:0',
|
||||
@@ -446,11 +447,17 @@ class PurchaseOrderController extends Controller
|
||||
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
||||
$grandTotal = $totalAmount + $taxAmount;
|
||||
|
||||
// 狀態轉移權限檢查
|
||||
if (isset($validated['status']) && $order->status !== $validated['status']) {
|
||||
if (!$order->canTransitionTo($validated['status'])) {
|
||||
return back()->withErrors(['error' => '您沒有權限將狀態從 ' . $order->status . ' 變更為 ' . $validated['status']]);
|
||||
}
|
||||
}
|
||||
// 1. 填充屬性但暫不儲存以捕捉變更
|
||||
$order->fill([
|
||||
'vendor_id' => $validated['vendor_id'],
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'order_date' => $validated['order_date'], // 新增
|
||||
'order_date' => $validated['order_date'],
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
@@ -459,11 +466,22 @@ class PurchaseOrderController extends Controller
|
||||
'status' => $validated['status'],
|
||||
'invoice_number' => $validated['invoice_number'] ?? null,
|
||||
'invoice_date' => $validated['invoice_date'] ?? null,
|
||||
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||
'invoice_amount' => (float) ($validated['invoice_amount'] ?? 0),
|
||||
]);
|
||||
|
||||
// 捕捉變更屬性以進行手動記錄
|
||||
// 捕捉變更屬性
|
||||
$dirty = $order->getDirty();
|
||||
|
||||
// 嚴格權限檢查:如果修改了 status 以外的任何欄位,必須具備編輯權限
|
||||
$otherChanges = array_diff(array_keys($dirty), ['status']);
|
||||
if (!empty($otherChanges)) {
|
||||
$canEdit = auth()->user()->hasRole('super-admin') || auth()->user()->can('purchase_orders.edit');
|
||||
if (!$canEdit) {
|
||||
throw new \Exception('您沒有權限修改採購單的基本內容,僅能執行流程異動(如:送審)。');
|
||||
}
|
||||
}
|
||||
|
||||
// 捕捉舊屬性以進行記錄
|
||||
$oldAttributes = [];
|
||||
$newAttributes = [];
|
||||
|
||||
@@ -476,14 +494,21 @@ class PurchaseOrderController extends Controller
|
||||
$order->saveQuietly();
|
||||
|
||||
// 2. 捕捉包含商品名稱的舊項目以進行比對
|
||||
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
$oldItemsCollection = $order->items()->get();
|
||||
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
|
||||
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
|
||||
// 注意:單位的獲取可能也需要透過 InventoryService,但目前假設單位的關聯是合法的(如果在同一模組)
|
||||
// 如果單位也在不同模組,則需要另外處理。這裡暫時假設可以動手水和一下基本單位名稱。
|
||||
|
||||
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
|
||||
$product = $oldProducts->get($item->product_id);
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product?->name,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'unit_name' => 'N/A', // 簡化處理,或可透過服務獲取
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
@@ -513,14 +538,19 @@ class PurchaseOrderController extends Controller
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// 重新獲取新項目以確保擁有最新的關聯
|
||||
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
// 重新獲取新項目並水和產品資料
|
||||
$newItemsCollection = $order->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' => $item->product?->name,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'unit_name' => 'N/A',
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
@@ -644,7 +674,7 @@ class PurchaseOrderController extends Controller
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已作廢');
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
|
||||
|
||||
133
app/Modules/Procurement/Controllers/ShippingOrderController.php
Normal file
133
app/Modules/Procurement/Controllers/ShippingOrderController.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Procurement\Models\ShippingOrder;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShippingOrderController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $coreService;
|
||||
protected $shippingService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
CoreServiceInterface $coreService,
|
||||
\App\Modules\Procurement\Services\ShippingService $shippingService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->coreService = $coreService;
|
||||
$this->shippingService = $shippingService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單管理'
|
||||
]);
|
||||
|
||||
/* 原有邏輯暫存
|
||||
$query = ShippingOrder::query();
|
||||
|
||||
// 搜尋
|
||||
if ($request->search) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('doc_no', 'like', "%{$request->search}%")
|
||||
->orWhere('customer_name', 'like', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->status && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$orders = $query->orderBy('id', 'desc')->paginate($perPage)->withQueryString();
|
||||
|
||||
// 水和倉庫與使用者
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
$userIds = $orders->getCollection()->pluck('created_by')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$orders->getCollection()->transform(function ($order) use ($warehouses, $users) {
|
||||
$order->warehouse_name = $warehouses->firstWhere('id', $order->warehouse_id)?->name ?? 'Unknown';
|
||||
$order->creator_name = $users->get($order->created_by)?->name ?? 'System';
|
||||
return $order;
|
||||
});
|
||||
|
||||
return Inertia::render('ShippingOrder/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'status', 'per_page']),
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
]);
|
||||
*/
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單建立'
|
||||
]);
|
||||
|
||||
/* 原有邏輯暫存
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
$products = $this->inventoryService->getAllProducts();
|
||||
|
||||
return Inertia::render('ShippingOrder/Create', [
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
'products' => $products->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'code' => $p->code,
|
||||
'unit_name' => $p->baseUnit?->name,
|
||||
]),
|
||||
]);
|
||||
*/
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
return back()->with('error', '出貨單管理功能正在製作中');
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單詳情'
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單編輯'
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
return back()->with('error', '出貨單管理功能正在製作中');
|
||||
}
|
||||
|
||||
public function post($id)
|
||||
{
|
||||
return back()->with('error', '出貨單管理功能正在製作中');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$order = ShippingOrder::findOrFail($id);
|
||||
if ($order->status !== 'draft') {
|
||||
return back()->withErrors(['error' => '僅能刪除草稿狀態的單據']);
|
||||
}
|
||||
$order->delete();
|
||||
return redirect()->route('delivery-notes.index')->with('success', '出貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -95,14 +95,15 @@ class VendorController extends Controller
|
||||
if (!$product) return null;
|
||||
|
||||
return (object) [
|
||||
'id' => (string) $pivot->id,
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'unit' => $product->baseUnit?->name ?? 'N/A',
|
||||
'baseUnit' => $product->baseUnit?->name,
|
||||
'largeUnit' => $product->largeUnit?->name,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) $pivot->last_price,
|
||||
'id' => (string) $product->id, // Frontend expects product ID here as p.id
|
||||
'name' => $product->name,
|
||||
'baseUnit' => $product->baseUnit ? (object)['name' => $product->baseUnit->name] : null,
|
||||
'largeUnit' => $product->largeUnit ? (object)['name' => $product->largeUnit->name] : null,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'purchase_unit' => $product->purchaseUnit?->name,
|
||||
'pivot' => (object) [
|
||||
'last_price' => (float) $pivot->last_price,
|
||||
],
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
@@ -119,7 +120,7 @@ class VendorController extends Controller
|
||||
'email' => $vendor->email,
|
||||
'address' => $vendor->address,
|
||||
'remark' => $vendor->remark,
|
||||
'supplyProducts' => $supplyProducts,
|
||||
'products' => $supplyProducts, // Changed from supplyProducts to products
|
||||
];
|
||||
|
||||
return Inertia::render('Vendor/Show', [
|
||||
|
||||
@@ -62,6 +62,11 @@ class PurchaseOrder extends Model
|
||||
return $this->belongsTo(Vendor::class);
|
||||
}
|
||||
|
||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Core\Models\User::class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -70,4 +75,50 @@ class PurchaseOrder extends Model
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否可以轉移至新狀態,並驗證權限。
|
||||
*/
|
||||
public function canTransitionTo(string $newStatus, $user = null): bool
|
||||
{
|
||||
$user = $user ?? auth()->user();
|
||||
if (!$user) return false;
|
||||
if ($user->hasRole('super-admin')) return true;
|
||||
|
||||
$currentStatus = $this->status;
|
||||
|
||||
// 定義合法的狀態轉移路徑與所需權限
|
||||
$transitions = [
|
||||
'draft' => [
|
||||
'pending' => 'purchase_orders.view', // 基本檢視者即可送審
|
||||
'cancelled' => 'purchase_orders.cancel',
|
||||
],
|
||||
'pending' => [
|
||||
'approved' => 'purchase_orders.approve',
|
||||
'draft' => 'purchase_orders.approve', // 退回草稿
|
||||
'cancelled' => 'purchase_orders.cancel',
|
||||
],
|
||||
'approved' => [
|
||||
'cancelled' => 'purchase_orders.cancel',
|
||||
'partial' => null, // 系統自動轉移,不需手動權限點
|
||||
],
|
||||
'partial' => [
|
||||
'completed' => null, // 系統自動轉移
|
||||
'closed' => 'purchase_orders.approve', // 手動結案通常需要核准權限
|
||||
'cancelled' => 'purchase_orders.cancel',
|
||||
],
|
||||
];
|
||||
|
||||
if (!isset($transitions[$currentStatus])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!array_key_exists($newStatus, $transitions[$currentStatus])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requiredPermission = $transitions[$currentStatus][$newStatus];
|
||||
|
||||
return $requiredPermission ? $user->can($requiredPermission) : true;
|
||||
}
|
||||
}
|
||||
|
||||
89
app/Modules/Procurement/Models/ShippingOrder.php
Normal file
89
app/Modules/Procurement/Models/ShippingOrder.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class ShippingOrder extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'customer_name',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'shipping_date',
|
||||
'total_amount',
|
||||
'tax_amount',
|
||||
'grand_total',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'posted_by',
|
||||
'posted_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'shipping_date' => 'date',
|
||||
'posted_at' => 'datetime',
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'grand_total' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(Activity $activity, string $eventName)
|
||||
{
|
||||
$snapshot = $activity->properties['snapshot'] ?? [];
|
||||
$snapshot['doc_no'] = $this->doc_no;
|
||||
$snapshot['customer_name'] = $this->customer_name;
|
||||
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'snapshot' => $snapshot
|
||||
]);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(ShippingOrderItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動產生單號
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'SHP-' . $today . '-';
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
35
app/Modules/Procurement/Models/ShippingOrderItem.php
Normal file
35
app/Modules/Procurement/Models/ShippingOrderItem.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ShippingOrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'shipping_order_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:4',
|
||||
'unit_price' => 'decimal:4',
|
||||
'subtotal' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function shippingOrder()
|
||||
{
|
||||
return $this->belongsTo(ShippingOrder::class);
|
||||
}
|
||||
|
||||
// 注意:在模組化架構下,跨模組關聯應謹慎使用或是直接在 Controller 水和 (Hydration)
|
||||
// 但為了開發便利,暫時保留對 Product 的關聯(如果 Product 在不同模組,可能無法直接 lazy load)
|
||||
}
|
||||
54
app/Modules/Procurement/Notifications/NewPurchaseOrder.php
Normal file
54
app/Modules/Procurement/Notifications/NewPurchaseOrder.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||
|
||||
class NewPurchaseOrder extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $purchaseOrder;
|
||||
protected $creatorName;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(PurchaseOrder $purchaseOrder, string $creatorName)
|
||||
{
|
||||
$this->purchaseOrder = $purchaseOrder;
|
||||
$this->creatorName = $creatorName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'purchase_order',
|
||||
'action' => 'created',
|
||||
'purchase_order_id' => $this->purchaseOrder->id,
|
||||
'code' => $this->purchaseOrder->code,
|
||||
'creator_name' => $this->creatorName,
|
||||
'message' => "{$this->creatorName} 建立了新的採購單:{$this->purchaseOrder->code}",
|
||||
'link' => route('purchase-orders.index', ['search' => $this->purchaseOrder->code]), // 暫時導向列表並搜尋,若有詳情頁可改
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Modules/Procurement/Observers/PurchaseOrderObserver.php
Normal file
31
app/Modules/Procurement/Observers/PurchaseOrderObserver.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Observers;
|
||||
|
||||
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||
use App\Modules\Procurement\Notifications\NewPurchaseOrder;
|
||||
use App\Modules\Core\Models\User;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class PurchaseOrderObserver
|
||||
{
|
||||
/**
|
||||
* Handle the PurchaseOrder "created" event.
|
||||
*/
|
||||
public function created(PurchaseOrder $purchaseOrder): void
|
||||
{
|
||||
// 找出有檢視採購單權限的使用者
|
||||
$users = User::permission('purchase_orders.view')->get();
|
||||
|
||||
// 排除建立者自己(避免自己收到自己的通知)
|
||||
// $users = $users->reject(function ($user) use ($purchaseOrder) {
|
||||
// return $user->id === $purchaseOrder->user_id;
|
||||
// });
|
||||
|
||||
$creatorName = $purchaseOrder->user ? $purchaseOrder->user->name : '系統';
|
||||
|
||||
if ($users->isNotEmpty()) {
|
||||
Notification::send($users, new NewPurchaseOrder($purchaseOrder, $creatorName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ use Illuminate\Support\ServiceProvider;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Procurement\Services\ProcurementService;
|
||||
|
||||
|
||||
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||
use App\Modules\Procurement\Observers\PurchaseOrderObserver;
|
||||
|
||||
class ProcurementServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
@@ -15,6 +19,6 @@ class ProcurementServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
PurchaseOrder::observe(PurchaseOrderObserver::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,27 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/purchase-orders/{id}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show');
|
||||
|
||||
Route::get('/purchase-orders/{id}/edit', [PurchaseOrderController::class, 'edit'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.edit');
|
||||
Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update');
|
||||
Route::match(['PUT', 'PATCH'], '/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->name('purchase-orders.update');
|
||||
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
|
||||
});
|
||||
|
||||
// 出貨單管理 (Delivery Notes)
|
||||
Route::middleware('permission:delivery_notes.view')->group(function () {
|
||||
Route::get('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'index'])->name('delivery-notes.index');
|
||||
|
||||
Route::middleware('permission:delivery_notes.create')->group(function () {
|
||||
Route::get('/delivery-notes/create', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'create'])->name('delivery-notes.create');
|
||||
Route::post('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'store'])->name('delivery-notes.store');
|
||||
});
|
||||
|
||||
Route::get('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'show'])->name('delivery-notes.show');
|
||||
|
||||
Route::middleware('permission:delivery_notes.edit')->group(function () {
|
||||
Route::get('/delivery-notes/{id}/edit', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'edit'])->name('delivery-notes.edit');
|
||||
Route::put('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'update'])->name('delivery-notes.update');
|
||||
Route::post('/delivery-notes/{id}/post', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'post'])->name('delivery-notes.post');
|
||||
});
|
||||
|
||||
Route::delete('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'destroy'])->middleware('permission:delivery_notes.delete')->name('delivery-notes.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,77 @@ class ProcurementService implements ProcurementServiceInterface
|
||||
return [
|
||||
'vendorsCount' => \App\Modules\Procurement\Models\Vendor::count(),
|
||||
'purchaseOrdersCount' => PurchaseOrder::count(),
|
||||
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
|
||||
'pendingOrdersCount' => PurchaseOrder::whereIn('status', ['approved', 'partial'])->count(), // 改為真正待進貨的狀態
|
||||
];
|
||||
}
|
||||
|
||||
public function updateReceivedQuantity(int $poItemId, float $quantity): void
|
||||
{
|
||||
$item = \App\Modules\Procurement\Models\PurchaseOrderItem::findOrFail($poItemId);
|
||||
$item->increment('received_quantity', $quantity);
|
||||
$item->refresh();
|
||||
|
||||
// Check PO status
|
||||
$po = $item->purchaseOrder;
|
||||
|
||||
// Load items to check completion
|
||||
$po->load('items');
|
||||
|
||||
$allReceived = $po->items->every(function ($i) {
|
||||
return $i->received_quantity >= $i->quantity;
|
||||
});
|
||||
|
||||
$anyReceived = $po->items->contains(function ($i) {
|
||||
return $i->received_quantity > 0;
|
||||
});
|
||||
|
||||
if ($allReceived) {
|
||||
$po->status = 'completed'; // or 'received' based on workflow
|
||||
} elseif ($anyReceived) {
|
||||
$po->status = 'partial';
|
||||
}
|
||||
|
||||
$po->save();
|
||||
}
|
||||
|
||||
public function searchPendingPurchaseOrders(string $query): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where(function($q) use ($query) {
|
||||
$q->where('code', 'like', "%{$query}%")
|
||||
->orWhereHas('vendor', function($vq) use ($query) {
|
||||
$vq->where('name', 'like', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function searchVendors(string $query): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::where('name', 'like', "%{$query}%")
|
||||
->orWhere('code', 'like', "%{$query}%")
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getPendingPurchaseOrders(): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(50)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getAllVendors(): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::orderBy('name')->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getVendorsByIds(array $ids): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']);
|
||||
}
|
||||
}
|
||||
|
||||
118
app/Modules/Procurement/Services/ShippingService.php
Normal file
118
app/Modules/Procurement/Services/ShippingService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Services;
|
||||
|
||||
use App\Modules\Procurement\Models\ShippingOrder;
|
||||
use App\Modules\Procurement\Models\ShippingOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShippingService
|
||||
{
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
public function createShippingOrder(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$order = ShippingOrder::create([
|
||||
'warehouse_id' => $data['warehouse_id'],
|
||||
'customer_name' => $data['customer_name'] ?? null,
|
||||
'shipping_date' => $data['shipping_date'],
|
||||
'status' => 'draft',
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
'total_amount' => $data['total_amount'] ?? 0,
|
||||
'tax_amount' => $data['tax_amount'] ?? 0,
|
||||
'grand_total' => $data['grand_total'] ?? 0,
|
||||
]);
|
||||
|
||||
foreach ($data['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'batch_number' => $item['batch_number'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'] ?? 0,
|
||||
'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)),
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
public function updateShippingOrder(ShippingOrder $order, array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($order, $data) {
|
||||
$order->update([
|
||||
'warehouse_id' => $data['warehouse_id'],
|
||||
'customer_name' => $data['customer_name'] ?? null,
|
||||
'shipping_date' => $data['shipping_date'],
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'total_amount' => $data['total_amount'] ?? 0,
|
||||
'tax_amount' => $data['tax_amount'] ?? 0,
|
||||
'grand_total' => $data['grand_total'] ?? 0,
|
||||
]);
|
||||
|
||||
// 簡單處理:刪除舊項目並新增
|
||||
$order->items()->delete();
|
||||
foreach ($data['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'batch_number' => $item['batch_number'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'] ?? 0,
|
||||
'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)),
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
public function post(ShippingOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
throw new \Exception('該單據已過帳或已取消。');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($order) {
|
||||
foreach ($order->items as $item) {
|
||||
// 尋找對應的庫存紀錄
|
||||
$inventory = $this->inventoryService->findInventoryByBatch(
|
||||
$order->warehouse_id,
|
||||
$item->product_id,
|
||||
$item->batch_number
|
||||
);
|
||||
|
||||
if (!$inventory || $inventory->quantity < $item->quantity) {
|
||||
$productName = $this->inventoryService->getProduct($item->product_id)?->name ?? 'Unknown';
|
||||
throw new \Exception("商品 [{$productName}] (批號: {$item->batch_number}) 庫存不足。");
|
||||
}
|
||||
|
||||
// 扣除庫存
|
||||
$this->inventoryService->decreaseInventoryQuantity(
|
||||
$inventory->id,
|
||||
$item->quantity,
|
||||
"出貨扣款: 單號 [{$order->doc_no}]",
|
||||
'ShippingOrder',
|
||||
$order->id
|
||||
);
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'posted_by' => auth()->id(),
|
||||
'posted_at' => now(),
|
||||
]);
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user