Compare commits
145 Commits
bd292b0868
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd0a047f2 | |||
| e3ceedc579 | |||
| f2e5ef121e | |||
| 7a1fc02dfc | |||
| 5f34855233 | |||
| bee8ecb55b | |||
| 0198b7de90 | |||
| b57a4feeab | |||
| 55a806051d | |||
| 6ca0bafd60 | |||
| adf13410ba | |||
| d52a215916 | |||
| 197df3bec4 | |||
| 2437aa2672 | |||
| a987f4345e | |||
| 89291918fd | |||
| 3f7a625191 | |||
| e11193c2a7 | |||
| 02e5f5d4ea | |||
| 36b90370a8 | |||
| 5290dd2cbe | |||
| 8e0252e8fc | |||
| 2fd5de96b2 | |||
| 951005c616 | |||
| ee0bacafc2 | |||
| 93390aad80 | |||
| 6b6e840f35 | |||
| e4c83ebd6d | |||
| d9edc603c7 | |||
| dda92393d2 | |||
| 47deab9804 | |||
| e921810f70 | |||
| 07b7d9b327 | |||
| 016366407c | |||
| ba50905626 | |||
| f4ed358393 | |||
| 7c395c89b5 | |||
| ed264b031a | |||
| dd2e63c08b | |||
| b498fe93ff | |||
| 6b324b4bd0 | |||
| a898873211 | |||
| f543b98d0f | |||
| 183583c739 | |||
| 58bd995cd8 | |||
| 036f4a4fb6 | |||
| 0a955fb993 | |||
| 7dac2d1f77 | |||
| 649af40919 | |||
| 5f8b2a1c2d | |||
| 4bbbde685d | |||
| 5e32526471 | |||
| f960aaaeb2 | |||
| 63e4f88a14 | |||
| e3df090afd | |||
| 878b90e2ad | |||
| 299cf37054 | |||
| 5668e17e61 | |||
| c4908533a8 | |||
| deef3baacc | |||
| ad91b08dbc | |||
| e406ecd63d | |||
| 455f945296 | |||
| aaa93a921e | |||
| 922b1fc877 | |||
| 3d4682a405 | |||
| 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 |
@@ -1,111 +0,0 @@
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範 (Activity Logging Skill)
|
||||
|
||||
本文件定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 後端實作核心 (Backend)
|
||||
|
||||
### 1.1 全域 ID 轉名稱邏輯 (Global ID Resolution)
|
||||
為了讓管理者能直覺看懂日誌,所有的 ID(如 `warehouse_id`, `created_by`)在記錄時都應自動解析為名稱。此邏輯應統一在 Model 的 `tapActivity` 中實作。
|
||||
|
||||
#### 關鍵實作參考:
|
||||
```php
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
// 🚩 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
// 1. Snapshot 快照:用於主描述的上下文(例如:單號、名稱)
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$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;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 複雜操作的日誌合併 (Log Consolidation)
|
||||
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
|
||||
|
||||
* **策略**:在 Service 層手動發送主日誌,並使用 `saveQuietly()` 更新單據屬性以抑止 Trait 的自動日誌。
|
||||
* **格式**:主日誌應包含 `items_diff` (品項差異) 與 `attributes/old` (單據狀態變更)。
|
||||
|
||||
```php
|
||||
// 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. 前端介面規範 (Frontend)
|
||||
|
||||
### 2.1 標籤命名規範 (Field Labels)
|
||||
前端顯示應完全移除「ID」字眼,提供最友善的閱讀體驗。
|
||||
|
||||
**檔案位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
```typescript
|
||||
const fieldLabels: Record<string, string> = {
|
||||
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
|
||||
created_by: '建立者', // ❌ 禁用「建立者 ID」
|
||||
completed_by: '完成者',
|
||||
status: '狀態',
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 特殊結構顯示
|
||||
* **品項異動**:前端應能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」的方式呈現表格(已在 `ActivityDetailDialog` 實作)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 開發檢核清單 (Checklist)
|
||||
|
||||
- [ ] **Model**: `tapActivity` 是否已處理 Collection 快照?
|
||||
- [ ] **Model**: 是否已實作全域 ID 至名稱的自動解析?
|
||||
- [ ] **Service**: 是否使用 `saveQuietly()` 避免產生重複的「單據已更新」日誌?
|
||||
- [ ] **UI**: `fieldLabels` 是否已移除所有「ID」字樣?
|
||||
- [ ] **UI**: 若有品項異動,是否已正確格式化傳入 `items_diff`?
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
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` 進行顯示控制。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,6 @@
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 開發框架規範說明書:ERP 系統 (star-erp)
|
||||
|
||||
## 1. 專案概述
|
||||
@@ -66,8 +62,10 @@ trigger: always_on
|
||||
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
* **核心要求:UI 規範與彈性設計 (重要)**:
|
||||
* 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。
|
||||
* **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。
|
||||
* **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。
|
||||
|
||||
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||
@@ -84,4 +82,19 @@ trigger: always_on
|
||||
* **執行 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. 部署與查修環境 (CI/CD & Troubleshooting)
|
||||
* **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。
|
||||
* **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`。
|
||||
* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。
|
||||
|
||||
## 11. 瀏覽器測試規範 (Browser Testing)
|
||||
當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊:
|
||||
|
||||
* **本地測試網址**:`http://localhost:8081/`
|
||||
* **預設管理員帳號**:`admin`
|
||||
* **預設管理員密碼**:`password`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port,以避免連線至錯誤的服務環境。
|
||||
57
.agents/rules/skill-trigger.md
Normal file
57
.agents/rules/skill-trigger.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 技能觸發規範 (Skill Trigger Rules)
|
||||
|
||||
本文件確保 AI 助手在對話中能**主動辨識**需要參照技能 (Skill) 的時機。
|
||||
Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
**若對話內容命中以下任一觸發條件,必須先使用 `view_file` 讀取對應的 `SKILL.md` 後再進行作業。**
|
||||
|
||||
---
|
||||
|
||||
## 觸發對照表
|
||||
|
||||
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|
||||
|---|---|---|
|
||||
| 操作紀錄、Activity Log、日誌、`tapActivity`、`LogsActivity`、`saveQuietly`、`activity()`、`items_diff` | **操作紀錄實作規範** | `.agents/skills/activity-logging/SKILL.md` |
|
||||
| 權限、permission、角色、role、`usePermission`、`<Can>`、`PermissionSeeder`、middleware protection | **權限管理與實作規範** | `.agents/skills/permission-management/SKILL.md` |
|
||||
| 跨模組、Service Interface、`Contracts`、模組間通訊、`ServiceProvider` 綁定、禁止跨模組引用 | **跨模組調用與通訊規範** | `.agents/skills/cross-module-communication/SKILL.md` |
|
||||
| 按鈕樣式、表格規範、圖標、分頁、Badge、Toast、表單、UI 統一、頁面佈局、`button-filled-*`、`button-outlined-*`、`lucide-react`、色彩系統 | **客戶端後台 UI 統一規範** | `.agents/skills/ui-consistency/SKILL.md` |
|
||||
| Git 分支、commit、push、合併、部署、`feature/`、`hotfix/`、`develop`、`main` | **Git 分支管理與開發規範** | `.agents/skills/git-workflows/SKILL.md` |
|
||||
| E2E、端到端測試、Playwright、`spec.ts`、功能驗證、自動化測試、回歸測試 | **E2E 端到端測試規範** | `.agents/skills/e2e-testing/SKILL.md` |
|
||||
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
|
||||
|
||||
---
|
||||
|
||||
## 強制觸發場景
|
||||
|
||||
以下場景**無論對話中是否出現觸發詞**,都必須主動載入對應 Skill:
|
||||
|
||||
### 🔴 新增功能或頁面時
|
||||
必須同時讀取:
|
||||
1. **permission-management** — 設定權限
|
||||
2. **ui-consistency** — 遵循 UI 規範
|
||||
3. **activity-logging** — 若涉及 Model CRUD,需加上操作紀錄
|
||||
4. **e2e-testing** — 確認是否需要新增對應的 E2E 測試
|
||||
|
||||
### 🔴 新增或修改 Model 時
|
||||
必須讀取:
|
||||
1. **activity-logging** — `tapActivity` 實作
|
||||
2. **cross-module-communication** — 確認是否涉及跨模組引用
|
||||
|
||||
### 🔴 Git 操作時
|
||||
必須讀取:
|
||||
1. **git-workflows** — 分支命名與 commit 格式
|
||||
|
||||
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||
必須讀取:
|
||||
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 即使你「記得」Skill 的大致內容,仍必須重新讀取 `SKILL.md`。
|
||||
> 因為 Skill 文件可能已經更新,且記憶中的內容可能不完整。
|
||||
285
.agents/skills/activity-logging/SKILL.md
Normal file
285
.agents/skills/activity-logging/SKILL.md
Normal file
@@ -0,0 +1,285 @@
|
||||
---
|
||||
name: 操作紀錄實作規範 (Activity Logging Skill)
|
||||
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範 (Activity Logging Skill)
|
||||
|
||||
本技能定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 啟用 Activity Log (Model 基本設定)
|
||||
|
||||
在 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(); // 若無變動則不記錄
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `tapActivity` 實作規範 (Backend 核心)
|
||||
|
||||
### 2.1 型別宣告:統一使用 `Contracts\Activity`
|
||||
|
||||
```php
|
||||
// ✅ 正確:使用介面
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
|
||||
// ❌ 禁止:使用具體類別
|
||||
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
|
||||
```
|
||||
|
||||
### 2.2 必須 `toArray()` 避免 Indirect modification error
|
||||
|
||||
```php
|
||||
// 🚩 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
// ... 操作 $properties ...
|
||||
|
||||
$activity->properties = $properties; // 最後整體回寫
|
||||
```
|
||||
|
||||
### 2.3 Snapshot 快照策略
|
||||
|
||||
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊。
|
||||
|
||||
```php
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['doc_no'] = $this->doc_no; // 單號
|
||||
$snapshot['name'] = $this->name; // 名稱
|
||||
$snapshot['warehouse_name'] = $this->warehouse?->name; // 關聯名稱
|
||||
$properties['snapshot'] = $snapshot;
|
||||
```
|
||||
|
||||
### 2.4 全域 ID 轉名稱邏輯 (ID Resolution)
|
||||
|
||||
所有的 ID(如 `warehouse_id`, `created_by`)在記錄時應自動解析為名稱。
|
||||
|
||||
#### 模組內 Model:可直接查詢
|
||||
|
||||
```php
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 同模組內的 Model 可以直接查詢
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 跨模組 Model:必須透過 Service Interface
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 依據跨模組通訊規範,若需解析其他模組的 ID(例如在 `Procurement` 模組中解析 `warehouse_id`),
|
||||
> **禁止**直接 `Warehouse::find()`,必須透過 Service Interface。
|
||||
|
||||
```php
|
||||
// ✅ 正確:透過 Service Interface 取得跨模組資料
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
|
||||
->getWarehouse($data['warehouse_id']);
|
||||
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `Core` 模組的 `User`, `Role`, `Tenant` 屬於全域例外,其他模組可直接查詢。
|
||||
> 詳見 [跨模組通訊規範](file:///home/mama/projects/star-erp/.agents/skills/cross-module-communication/SKILL.md)。
|
||||
|
||||
### 2.5 完整 `tapActivity` 範例(參考 PurchaseOrder)
|
||||
|
||||
```php
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
// 🚩 轉換為陣列
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
// 1. Snapshot 快照
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['po_number'] = $this->code;
|
||||
$snapshot['vendor_name'] = $this->vendor?->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 2. ID 轉名稱
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 全域例外:User 可直接查
|
||||
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name ?? $data[$f];
|
||||
}
|
||||
}
|
||||
// 同模組:可直接查
|
||||
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
|
||||
$data['vendor_id'] = Vendor::find($data['vendor_id'])?->name ?? $data['vendor_id'];
|
||||
}
|
||||
// 跨模組:必須透過 Service Interface
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
|
||||
->getWarehouse($data['warehouse_id']);
|
||||
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
// 3. 合併 activityProperties (手動傳入的 items_diff 等)
|
||||
if (!empty($this->activityProperties)) {
|
||||
$properties = array_merge($properties, $this->activityProperties);
|
||||
}
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 複雜操作的日誌合併 (Log Consolidation)
|
||||
|
||||
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
|
||||
|
||||
### 3.1 手動記錄必須自行過濾差異
|
||||
|
||||
```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');
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `saveQuietly()` + 手動日誌 合併策略
|
||||
|
||||
```php
|
||||
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');
|
||||
});
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `saveQuietly()` 會繞過 Model Events(如自動單號產生)。
|
||||
> 若 Model 有 `creating`/`updating` 事件產生單號,需在 Service 中手動處理。
|
||||
|
||||
---
|
||||
|
||||
## 4. 後端 Controller 映射 (Subject Map)
|
||||
|
||||
新增 Model 時,必須同步在 `ActivityLogController::getSubjectMap()` 加入中文映射。
|
||||
|
||||
**位置**: `app/Modules/Core/Controllers/ActivityLogController.php`
|
||||
|
||||
```php
|
||||
private function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
// ... 新增此行
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端介面規範 (Frontend)
|
||||
|
||||
### 5.1 標籤命名規範 (Field Labels)
|
||||
|
||||
前端顯示應完全移除「ID」字眼,提供最友善的閱讀體驗。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
|
||||
```typescript
|
||||
const fieldLabels: Record<string, string> = {
|
||||
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
|
||||
created_by: '建立者', // ❌ 禁用「建立者 ID」
|
||||
completed_by: '完成者',
|
||||
status: '狀態',
|
||||
// 新增 Model 的欄位翻譯 ...
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 `nameParams` 必須在兩處同步更新
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `nameParams` 在 `LogTable.tsx` 和 `ActivityDetailDialog.tsx` 中各有一份,
|
||||
> 新增時**必須兩處同步更新**,否則會導致列表與詳情頁顯示不一致。
|
||||
|
||||
| 檔案 | 用途 |
|
||||
|---|---|
|
||||
| `resources/js/Components/ActivityLog/LogTable.tsx` | 列表頁的描述文字 |
|
||||
| `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` | 對話框標題 |
|
||||
|
||||
### 5.3 特殊結構顯示
|
||||
|
||||
* **品項異動**:前端已能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」方式呈現表格。
|
||||
* **顯示過濾邏輯**(已內建於 `ActivityDetailDialog`):
|
||||
- **Created**: 顯示初始化欄位
|
||||
- **Updated**: 僅顯示有變動的欄位 (`isChanged` 判斷)
|
||||
- **Deleted**: 顯示刪除前的完整資料
|
||||
|
||||
---
|
||||
|
||||
## 6. 開發檢核清單 (Checklist)
|
||||
|
||||
- [ ] **Model**: 是否已設定 `logOnlyDirty` + `dontSubmitEmptyLogs`?
|
||||
- [ ] **Model**: `tapActivity` 型別是否使用 `Contracts\Activity`?
|
||||
- [ ] **Model**: `tapActivity` 是否已使用 `toArray()` 處理 Collection?
|
||||
- [ ] **Model**: 是否已實作 Snapshot(關鍵識別資訊)?
|
||||
- [ ] **Model**: ID 轉名稱是否遵守跨模組規範(Core 例外,其餘需透過 Interface)?
|
||||
- [ ] **Service**: 是否使用 `saveQuietly()` 搭配手動 `activity()` 避免重複日誌?
|
||||
- [ ] **Controller**: `ActivityLogController::getSubjectMap()` 是否已新增 Model 中文映射?
|
||||
- [ ] **UI**: `fieldLabels` 是否已新增欄位中文翻譯?
|
||||
- [ ] **UI**: `nameParams` 是否已在 `LogTable` 和 `ActivityDetailDialog` 兩處同步?
|
||||
137
.agents/skills/cross-module-communication/SKILL.md
Normal file
137
.agents/skills/cross-module-communication/SKILL.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: 跨模組調用與通訊規範 (Cross-Module Communication)
|
||||
description: 規範 Laravel Modular Monolith 架構下,不同業務模組中如何彼此調用資料與邏輯,包含禁止項目、Interface 實作、與 Service 綁定規則。
|
||||
---
|
||||
|
||||
# 跨模組調用與通訊規範 (Cross-Module Communication)
|
||||
|
||||
為了確保專案的「模組化單體架構 (Modular Monolith)」的獨立性與可維護性,當遇到**需要跨越不同業務模組存取資料或調用功能**的情境時,請嚴格遵守以下規範。
|
||||
|
||||
## 🚫 絕對禁止的行為 (Strict Prohibitions)
|
||||
|
||||
* **禁止跨模組 Eloquent 關聯(例外除外)**
|
||||
* **禁止跨模組直接引入 (use) Model**
|
||||
* **禁止跨模組直接實例化 (new) Service**
|
||||
|
||||
---
|
||||
|
||||
## 🌟 允許的全域例外 (Global Exceptions)
|
||||
|
||||
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
|
||||
|
||||
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model:
|
||||
1. **`App\Modules\Core\Models\User`**
|
||||
2. **`App\Modules\Core\Models\Role`**
|
||||
3. **`App\Modules\Core\Models\Tenant`**
|
||||
|
||||
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`)**絕對不能**反過來直接 `use` 外部業務模組的 Model,仍必須透過外部模組的 Service Interface 來索取資料。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 正確的跨模組調用流程:合約與依賴反轉
|
||||
|
||||
所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。
|
||||
|
||||
### Step 1: 在被調用的模組定義合約 (Interface)
|
||||
|
||||
如果 `Inventory` 模組需要提供功能給外部使用,請在 `app/Modules/Inventory/Contracts/` 建立 Interface 檔案。
|
||||
|
||||
```php
|
||||
namespace App\Modules\Inventory\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface InventoryServiceInterface
|
||||
{
|
||||
public function getActiveWarehouses(): Collection;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 實作介面並在自己模組的 ServiceProvider 註冊
|
||||
|
||||
由 `Inventory` 模組自己的 Service 來實作上述介面。
|
||||
|
||||
```php
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class InventoryService implements InventoryServiceInterface
|
||||
{
|
||||
public function getActiveWarehouses(): Collection
|
||||
{
|
||||
return Warehouse::where('is_active', true)
|
||||
->select(['id', 'name', 'code'])
|
||||
->get();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然後進入 `app/Modules/Inventory/InventoryServiceProvider.php` 完成綁定:
|
||||
|
||||
```php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
|
||||
class InventoryServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 調用方透過依賴注入 (DI) 使用服務
|
||||
|
||||
當 `Procurement` 模組需要取得倉庫資料時,必須透過**建構子注入**或**方法注入**取得 `InventoryServiceInterface`。
|
||||
|
||||
```php
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InventoryServiceInterface $inventoryService
|
||||
) {}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$warehouses = $this->inventoryService->getActiveWarehouses();
|
||||
|
||||
return Inertia::render('Procurement/PurchaseOrder/Create', [
|
||||
'warehouses' => $warehouses
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 跨模組資料回傳的注意事項 (Data Hydration)
|
||||
|
||||
* **回傳純粹資料**:建議在 Service 中用 `with()` 載入好關聯,或者直接轉為原生的 Array 或有具體結構的 DTO,避免依賴 Lazy Loading。
|
||||
* **手動組合 (Manual Hydration)**:若某個頁面需要合併兩個模組的資料,必須在 Controller 層級呼叫兩個不同的 Service Interface 後,手動合併。
|
||||
|
||||
### 範例:手動合併資料
|
||||
```php
|
||||
// 正確示範:在各自模組取資料,並手動組裝
|
||||
$orders = $this->orderService->getOrders();
|
||||
$userIds = $orders->pluck('user_id')->unique()->toArray();
|
||||
$users = $this->coreUserService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$mergedData = $orders->map(function ($order) use ($users) {
|
||||
// 將使用者資料手動附加上去
|
||||
$order->user_name = $users->get($order->user_id)->name ?? 'Unknown';
|
||||
return $order;
|
||||
});
|
||||
```
|
||||
266
.agents/skills/e2e-testing/SKILL.md
Normal file
266
.agents/skills/e2e-testing/SKILL.md
Normal file
@@ -0,0 +1,266 @@
|
||||
---
|
||||
name: E2E 端到端測試規範 (E2E Testing with Playwright)
|
||||
description: 規範 Playwright 端到端測試的撰寫慣例、目錄結構、共用工具與執行方式,確保所有 E2E 測試保持一致性與可維護性。
|
||||
---
|
||||
|
||||
# E2E 端到端測試規範 (E2E Testing with Playwright)
|
||||
|
||||
本技能定義了 Star ERP 系統中端到端 (E2E) 測試的實作標準,使用 Playwright 模擬真實使用者操作瀏覽器,驗證 UI 顯示與功能流程的正確性。
|
||||
|
||||
---
|
||||
|
||||
## 1. 專案結構
|
||||
|
||||
### 1.1 目錄配置
|
||||
|
||||
```
|
||||
star-erp/
|
||||
├── playwright.config.ts # Playwright 設定檔
|
||||
├── e2e/ # E2E 測試根目錄
|
||||
│ ├── helpers/ # 共用工具函式
|
||||
│ │ └── auth.ts # 登入 helper
|
||||
│ ├── screenshots/ # 測試截圖存放
|
||||
│ ├── auth.spec.ts # 認證相關測試(登入、登出)
|
||||
│ ├── inventory.spec.ts # 庫存模組測試
|
||||
│ ├── products.spec.ts # 商品模組測試
|
||||
│ └── {module}.spec.ts # 依模組命名
|
||||
├── playwright-report/ # HTML 測試報告(自動產生,已 gitignore)
|
||||
└── test-results/ # 失敗截圖與錄影(自動產生,已 gitignore)
|
||||
```
|
||||
|
||||
### 1.2 命名規範
|
||||
|
||||
| 項目 | 規範 | 範例 |
|
||||
|---|---|---|
|
||||
| 測試檔案 | 小寫,依模組命名 `.spec.ts` | `inventory.spec.ts` |
|
||||
| 測試群組 | `test.describe('中文功能名稱')` | `test.describe('庫存查詢')` |
|
||||
| 測試案例 | 中文描述「**應**」開頭 | `test('應顯示庫存清單')` |
|
||||
| 截圖檔案 | `{module}-{scenario}.png` | `inventory-search-result.png` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 設定檔 (playwright.config.ts)
|
||||
|
||||
### 2.1 核心設定
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:8081', // Sail 開發伺服器
|
||||
screenshot: 'only-on-failure', // 失敗時自動截圖
|
||||
video: 'retain-on-failure', // 失敗時保留錄影
|
||||
trace: 'on-first-retry', // 重試時收集 trace
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 重要注意事項
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `baseURL` 必須指向本機 Sail 開發伺服器(預設 `http://localhost:8081`)。
|
||||
> 確保測試前已執行 `./vendor/bin/sail up -d` 與 `./vendor/bin/sail npm run dev`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 共用工具 (Helpers)
|
||||
|
||||
### 3.1 登入 Helper
|
||||
|
||||
位置:`e2e/helpers/auth.ts`
|
||||
|
||||
```typescript
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 共用登入函式
|
||||
* 使用測試帳號登入 ERP 系統
|
||||
*/
|
||||
export async function login(page: Page, username = 'mama', password = 'mama9453') {
|
||||
await page.goto('/');
|
||||
await page.fill('#username', username);
|
||||
await page.fill('#password', password);
|
||||
await page.getByRole('button', { name: '登入系統' }).click();
|
||||
// 等待儀表板載入完成
|
||||
await page.waitForSelector('text=系統概況', { timeout: 10000 });
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 使用方式
|
||||
|
||||
```typescript
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test('應顯示庫存清單', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/inventory/stock-query');
|
||||
// ...斷言
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 測試撰寫規範
|
||||
|
||||
### 4.1 測試結構模板
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('模組功能名稱', () => {
|
||||
|
||||
// 若整個 describe 都需要登入,使用 beforeEach
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應正確顯示頁面標題與關鍵元素', async ({ page }) => {
|
||||
await page.goto('/target-page');
|
||||
|
||||
// 驗證頁面標題
|
||||
await expect(page.getByText('頁面標題')).toBeVisible();
|
||||
|
||||
// 驗證表格存在
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能執行 CRUD 操作', async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 斷言 (Assertions) 慣例
|
||||
|
||||
| 場景 | 優先使用 | 避免使用 |
|
||||
|---|---|---|
|
||||
| 驗證頁面載入 | `page.getByText('關鍵文字')` | `page.waitForURL()` ※ |
|
||||
| 驗證元素存在 | `expect(locator).toBeVisible()` | `.count() > 0` |
|
||||
| 驗證表格資料 | `page.locator('table tbody tr')` | 硬編碼行數 |
|
||||
| 等待操作完成 | `expect().toBeVisible({ timeout })` | `page.waitForTimeout()` |
|
||||
|
||||
> [!NOTE]
|
||||
> ※ Star ERP 使用 Inertia.js,頁面導航不一定改變 URL(例如儀表板路由為 `/`)。
|
||||
> 因此**優先使用頁面內容驗證**,而非依賴 URL 變化。
|
||||
|
||||
### 4.3 選擇器優先順序
|
||||
|
||||
依照 Playwright 官方建議,選擇器優先順序為:
|
||||
|
||||
1. **Role** — `page.getByRole('button', { name: '登入系統' })`
|
||||
2. **Text** — `page.getByText('系統概況')`
|
||||
3. **Label** — `page.getByLabel('帳號')`
|
||||
4. **Placeholder** — `page.getByPlaceholder('請輸入...')`
|
||||
5. **Test ID** — `page.getByTestId('submit-btn')`(需在元件加 `data-testid`)
|
||||
6. **CSS** — `page.locator('#username')`(最後手段)
|
||||
|
||||
### 4.4 禁止事項
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止:硬等待(不可預期的等待時間)
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// ✅ 正確:等待特定條件
|
||||
await expect(page.getByText('操作成功')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// ❌ 禁止:在測試中寫死測試資料的 ID
|
||||
await page.goto('/products/42/edit');
|
||||
|
||||
// ✅ 正確:從頁面互動導航
|
||||
await page.locator('table tbody tr').first().getByRole('button', { name: '編輯' }).click();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 截圖與視覺回歸
|
||||
|
||||
### 5.1 手動截圖(文件用途)
|
||||
|
||||
```typescript
|
||||
// 成功截圖存於 e2e/screenshots/
|
||||
await page.screenshot({
|
||||
path: 'e2e/screenshots/inventory-list.png',
|
||||
fullPage: true,
|
||||
});
|
||||
```
|
||||
|
||||
### 5.2 視覺回歸測試(偵測 UI 變化)
|
||||
|
||||
```typescript
|
||||
test('庫存頁面 UI 應保持一致', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/inventory/stock-query');
|
||||
// 比對截圖,pixel 級差異會報錯
|
||||
await expect(page).toHaveScreenshot('stock-query.png', {
|
||||
maxDiffPixelRatio: 0.01, // 容許 1% 差異(動態資料)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 首次執行 `toHaveScreenshot()` 會自動建立基準截圖。
|
||||
> 後續執行會與基準比對,更新基準用:`npx playwright test --update-snapshots`
|
||||
|
||||
---
|
||||
|
||||
## 6. 執行指令速查
|
||||
|
||||
```bash
|
||||
# 執行所有 E2E 測試
|
||||
npx playwright test
|
||||
|
||||
# 執行特定模組測試
|
||||
npx playwright test e2e/login.spec.ts
|
||||
|
||||
# UI 互動模式(可視化瀏覽器操作)
|
||||
npx playwright test --ui
|
||||
|
||||
# 帶頭模式(顯示瀏覽器畫面)
|
||||
npx playwright test --headed
|
||||
|
||||
# 產生 HTML 報告並開啟
|
||||
npx playwright test --reporter=html
|
||||
npx playwright show-report
|
||||
|
||||
# 更新視覺回歸基準截圖
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
# 只執行特定測試案例(用 -g 篩選名稱)
|
||||
npx playwright test -g "登入"
|
||||
|
||||
# Debug 模式(逐步執行)
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 開發檢核清單 (Checklist)
|
||||
|
||||
### 新增頁面或功能時:
|
||||
|
||||
- [ ] 是否已為新頁面建立對應的 `.spec.ts` 測試檔?
|
||||
- [ ] 測試是否覆蓋主要的 Happy Path(正常操作流程)?
|
||||
- [ ] 測試是否覆蓋關鍵的 Error Path(錯誤處理)?
|
||||
- [ ] 共用的登入步驟是否使用 `helpers/auth.ts`?
|
||||
- [ ] 斷言是否優先使用頁面內容而非 URL?
|
||||
- [ ] 選擇器是否遵循優先順序(Role > Text > Label > CSS)?
|
||||
- [ ] 測試是否可獨立執行(不依賴其他測試的狀態)?
|
||||
|
||||
### 提交程式碼前:
|
||||
|
||||
- [ ] 全部 E2E 測試是否通過?(`npx playwright test`)
|
||||
- [ ] 是否有遺留的 `test.only` 或 `test.skip`?
|
||||
51
.agents/skills/git-workflows/SKILL.md
Normal file
51
.agents/skills/git-workflows/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: Git 分支管理與開發規範 (Git Workflow)
|
||||
description: 規範開發過程中的 Git 分支架構、合併限制、環境部署流程以及提交訊息格式。
|
||||
---
|
||||
|
||||
# Git 分支管理與開發規範 (Git Workflow)
|
||||
|
||||
為了確保系統穩定性與發布紀律,所有開發者與 AI 助手必須嚴格遵守以下環境發布流程與時段限制。
|
||||
|
||||
## 1. 分支架構與環境定義
|
||||
|
||||
| 分支 | 環境 | 用途描述 | 合併來源 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`dev`** | 本機開發 | 日常開發與功能實作。 | `feature/*` |
|
||||
| **`demo`** | 測試/預佈署 | 鏡像生產環境。用於正式上線前的最終驗證。 | `dev` |
|
||||
| **`main`** | 生產環境 | 正式版本分支。僅存放透過 `demo` 驗證後的代碼。 | `demo` |
|
||||
|
||||
## 2. 發布時段與約束 (Release Window)
|
||||
|
||||
### Main 分支發布限制 (Mandatory)
|
||||
1. **強制規範**:若執行推送/合併指令時未明確包含目標分支,**嚴禁** 自行預設或推論為 `main`。我必須先詢問使用者:「請問要推送到哪一個目標分支?(dev / demo / main)」。
|
||||
2. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。
|
||||
3. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`:
|
||||
- AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。
|
||||
- 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。
|
||||
4. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。
|
||||
|
||||
## 3. 開發與修復流程 (SOP)
|
||||
|
||||
### 標準開發流程
|
||||
1. `feature/*` -> `dev` (隨時合併,主要測試點)。
|
||||
2. `dev` -> `demo` (隨時合併,進行類生產環境測試)。
|
||||
3. `demo` -> `main` (僅限允許時段進行,正式上線)。
|
||||
|
||||
### 緊急修復流程 (Hotfix)
|
||||
1. 直接從 `main` 建立 `hotfix/*` 分支進行修復。
|
||||
2. 修復完成並通過測試後合併回 `main`。
|
||||
3. **重要同步**:修復後的程式碼必須立即合併回 `demo` 與 `dev`,確保各環境修復同步。
|
||||
|
||||
## 4. 提交訊息規範 (Commit Messages)
|
||||
|
||||
提交訊息必須包含以下前綴:
|
||||
- `[FIX]`:修復 Bug。
|
||||
- `[FEAT]`:新增功能。
|
||||
- `[DOCS]`:文件更新。
|
||||
- `[STYLE]`:UI/格式調整。
|
||||
- `[REFACTOR]`:重構。
|
||||
|
||||
---
|
||||
> [!IMPORTANT]
|
||||
> 身為 AI 助手 (Antigravity),我會監控合併對象與當前時間。若您的命令涉及合併至 `main` 且不在允許時段內,我會優先進行安全提醒。
|
||||
206
.agents/skills/permission-management/SKILL.md
Normal file
206
.agents/skills/permission-management/SKILL.md
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
name: 權限管理與實作規範
|
||||
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||
---
|
||||
|
||||
# 權限管理與實作規範
|
||||
|
||||
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定義權限 (Backend Seeder)
|
||||
|
||||
所有權限皆定義於 `database/seeders/PermissionSeeder.php`。
|
||||
|
||||
### 步驟:
|
||||
|
||||
1. 開啟 `database/seeders/PermissionSeeder.php`。
|
||||
2. 在 `$permissions` 關聯陣列中新增功能對應的權限。
|
||||
* **命名慣例**:`{resource}.{action}`(例如:`system.view_logs`, `products.create`)
|
||||
* **格式**:`'權限字串' => '中文動作名稱'`
|
||||
* 常用動作:`view`, `create`, `edit`, `delete`, `approve`, `cancel`, `export`
|
||||
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 1. 新增權限(注意:是 key => value 格式)
|
||||
$permissions = [
|
||||
// ... 現有權限
|
||||
'utility_fees.view' => '檢視',
|
||||
'utility_fees.create' => '建立',
|
||||
'utility_fees.edit' => '編輯',
|
||||
'utility_fees.delete' => '刪除',
|
||||
];
|
||||
|
||||
// 2. 分配給角色
|
||||
$admin->givePermissionTo([
|
||||
// ... 現有權限
|
||||
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
|
||||
]);
|
||||
```
|
||||
|
||||
### 現有角色定義:
|
||||
|
||||
| 角色 | 說明 | 權限範圍 |
|
||||
|---|---|---|
|
||||
| `super-admin` | 系統管理員 | 自動擁有所有權限(`Permission::all()`) |
|
||||
| `admin` | 一般管理員 | 大部分權限(除角色管理外) |
|
||||
| `warehouse-manager` | 倉庫管理員 | 庫存、盤點、調撥、進貨、門市叫貨 |
|
||||
| `purchaser` | 採購人員 | 商品檢視、採購單、退貨、供應商、進貨 |
|
||||
| `viewer` | 檢視人員 | 僅限各模組的 `.view` 權限 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 套用資料庫變更 (Multi-tenancy)
|
||||
|
||||
修改 Seeder 後,必須在**中央與所有租戶**同步執行。
|
||||
|
||||
```bash
|
||||
# 對所有租戶執行 Seeder
|
||||
./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 僅執行 `db:seed` 只會更新中央資料庫。務必使用 `tenants:seed` 確保所有租戶同步。
|
||||
|
||||
---
|
||||
|
||||
## 3. 路由保護 (Backend Middleware)
|
||||
|
||||
路由保護定義在各模組自己的 `app/Modules/{ModuleName}/Routes/web.php` 中。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 路由檔在各模組內(如 `app/Modules/Finance/Routes/web.php`),**不是**全域的 `routes/web.php`。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 單一權限保護
|
||||
Route::middleware('permission:utility_fees.view')->group(function () {
|
||||
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
|
||||
Route::get('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'show'])->name('utility-fees.show');
|
||||
});
|
||||
|
||||
// 巢狀權限群組
|
||||
Route::middleware('permission:utility_fees.create')->group(function () {
|
||||
Route::get('/utility-fees/create', [UtilityFeeController::class, 'create'])->name('utility-fees.create');
|
||||
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
|
||||
});
|
||||
|
||||
// 單行 middleware
|
||||
Route::delete('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'destroy'])
|
||||
->middleware('permission:utility_fees.delete')
|
||||
->name('utility-fees.destroy');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置權限群組名稱 (Backend UI Config)
|
||||
|
||||
為了讓新權限在「角色與權限」管理介面中正確分組並顯示中文標題,需修改 Controller。
|
||||
|
||||
**位置**: `app/Modules/Core/Controllers/RoleController.php` → `getGroupedPermissions()`
|
||||
|
||||
```php
|
||||
$groupDefinitions = [
|
||||
'products' => '商品資料管理',
|
||||
'warehouses' => '倉庫管理',
|
||||
'inventory' => '庫存資料管理',
|
||||
// ...
|
||||
'utility_fees' => '公共事業費管理', // ✅ 新增此行
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 未加入 `$groupDefinitions` 的權限群組仍會顯示,但標題會以原始 key(英文)呈現。
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端權限判斷 (React)
|
||||
|
||||
### 5.1 方式一:`usePermission` Hook(在邏輯中判斷)
|
||||
|
||||
**位置**: `resources/js/hooks/usePermission.ts`
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
|
||||
export default function ProductIndex() {
|
||||
const { can, canAny, isSuperAdmin } = usePermission();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{can('products.create') && <Button>新增商品</Button>}
|
||||
{canAny(['products.edit', 'products.delete']) && <ManageDropdown />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Hook 完整介面:
|
||||
|
||||
| 方法 | 說明 |
|
||||
|---|---|
|
||||
| `can(permission)` | 檢查是否擁有**指定**權限 |
|
||||
| `canAny(permissions[])` | 檢查是否擁有**任一**權限 |
|
||||
| `canAll(permissions[])` | 檢查是否擁有**所有**權限 |
|
||||
| `hasRole(role)` | 檢查是否擁有**指定**角色 |
|
||||
| `hasAnyRole(roles[])` | 檢查是否擁有**任一**角色 |
|
||||
| `hasAllRoles(roles[])` | 檢查是否擁有**所有**角色 |
|
||||
| `isSuperAdmin()` | 是否為超級管理員 |
|
||||
|
||||
> 所有方法對 `super-admin` 角色自動回傳 `true`。
|
||||
|
||||
### 5.2 方式二:`<Can>` / `<HasRole>` / `<CanAll>` 元件(在 JSX 中包裹)
|
||||
|
||||
**位置**: `resources/js/Components/Permission/Can.tsx`
|
||||
|
||||
```tsx
|
||||
import { Can, HasRole, CanAll } from '@/Components/Permission/Can';
|
||||
|
||||
// 單一權限
|
||||
<Can permission="products.create">
|
||||
<Button>新增商品</Button>
|
||||
</Can>
|
||||
|
||||
// 任一權限(OR 邏輯)
|
||||
<Can permission={['products.edit', 'products.delete']}>
|
||||
<ManageDropdown />
|
||||
</Can>
|
||||
|
||||
// 所有權限都必須有(AND 邏輯)
|
||||
<CanAll permissions={['products.edit', 'products.delete']}>
|
||||
<Button>完整管理</Button>
|
||||
</CanAll>
|
||||
|
||||
// 角色判斷
|
||||
<HasRole role="admin">
|
||||
<Link href="/admin">管理後台</Link>
|
||||
</HasRole>
|
||||
|
||||
// Fallback 支援
|
||||
<Can permission="products.delete" fallback={<span className="text-gray-400">無權限</span>}>
|
||||
<Button variant="destructive">刪除</Button>
|
||||
</Can>
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> UI 規範要求:所有可操作按鈕(新增、編輯、刪除)**必須**包裹 `<Can>` 元件或使用 `can()` 判斷。
|
||||
> 詳見 [UI 統一規範](file:///home/mama/projects/star-erp/.agents/skills/ui-consistency/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 開發檢核清單 (Checklist)
|
||||
|
||||
### 後端
|
||||
- [ ] `PermissionSeeder.php` 已新增權限字串(`'key' => '中文動作名稱'` 格式)。
|
||||
- [ ] `PermissionSeeder.php` 已將新權限分配給 `admin` 及其他適用角色。
|
||||
- [ ] 已執行 `./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder` 同步所有租戶。
|
||||
- [ ] `RoleController.php` 的 `$groupDefinitions` 已新增權限群組中文名稱。
|
||||
- [ ] 模組路由 (`app/Modules/{ModuleName}/Routes/web.php`) 已加上 `middleware('permission:...')` 保護。
|
||||
|
||||
### 前端
|
||||
- [ ] 頁面按鈕已使用 `usePermission` Hook 或 `<Can>` 元件進行權限控制。
|
||||
- [ ] 所有可操作按鈕都包裹於權限判斷中(符合 UI 統一規範)。
|
||||
@@ -80,7 +80,7 @@ tooltip
|
||||
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
|
||||
|
||||
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
|
||||
<div className="text-[#01ab83]">...</div>
|
||||
<div className="text-primary-main">...</div>
|
||||
```
|
||||
|
||||
### 2.2 灰階 (Grey Scale)
|
||||
@@ -319,7 +319,7 @@ 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]" />
|
||||
<Users className="h-6 w-6 text-primary-main" />
|
||||
使用者管理
|
||||
</h1>
|
||||
|
||||
@@ -584,7 +584,7 @@ export default function ResourceIndex() {
|
||||
<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]" />
|
||||
<IconComponent className="h-6 w-6 text-primary-main" />
|
||||
頁面標題
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
@@ -781,7 +781,75 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
||||
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
||||
|
||||
## 11.6 日期輸入框樣式 (Date Input Style)
|
||||
## 11.6 數字輸入框規範 (Numeric Inputs)
|
||||
|
||||
當需求為輸入**整數**數量(例如:實際產出數量、標準產出量)時,**嚴禁自行開發組合 `Plus` (+) 與 `Minus` (-) 按鈕的複合元件**。
|
||||
|
||||
**必須使用原生 HTML5 數字輸入與屬性**:
|
||||
1. 使用 `<Input type="number" />` 確保預設渲染瀏覽器原生的上下調整小箭頭 (Spinner)。
|
||||
2. 針對整數需求,固定加上 `step="1"` 屬性。
|
||||
3. 視需求加上 `min` 與 `max` 控制上下限。
|
||||
|
||||
這樣既能保持與現有「新增配方」等模組的「標準產出量」欄位行為高度一致,亦能維持畫面的極簡風格。
|
||||
|
||||
```tsx
|
||||
// ✅ 正確:依賴原生行為
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max={outputQuantity}
|
||||
value={actualOutputQuantity}
|
||||
onChange={(e) => setActualOutputQuantity(e.target.value)}
|
||||
className="h-9 w-24 text-center"
|
||||
/>
|
||||
|
||||
// ❌ 錯誤:過度設計、浪費空間與破壞一致性
|
||||
<div className="flex">
|
||||
<Button><Minus /></Button>
|
||||
<Input type="number" />
|
||||
<Button><Plus /></Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 11.7 日期顯示規範 (Date Display)
|
||||
|
||||
前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。
|
||||
|
||||
### 可用函式
|
||||
|
||||
| 函式 | 說明 | 輸出範例 |
|
||||
|---|---|---|
|
||||
| `formatDate(dateStr)` | **智慧格式**:自動判斷是否包含時間 | `2024-03-06` 或 `2024-03-06 08:30:00` |
|
||||
| `formatDate(dateStr, 'yyyy-MM-dd')` | 指定格式輸出 | `2024-03-06` |
|
||||
| `formatDateOnly(dateStr)` | 強制僅顯示日期 | `2024-03-06` |
|
||||
|
||||
### 智慧格式切換邏輯
|
||||
|
||||
`formatDate` 會自動判斷原始資料:
|
||||
- 若時間部分為 `00:00:00`(通常代表後端僅提供日期)→ 僅顯示 `YYYY-MM-DD`
|
||||
- 若時間部分有值 → 顯示 `YYYY-MM-DD HH:mm:ss`
|
||||
- 若輸入為 `null` / `undefined` / 無效字串 → 顯示 `"-"`
|
||||
|
||||
### 使用範例
|
||||
|
||||
```tsx
|
||||
import { formatDate, formatDateOnly } from "@/lib/date";
|
||||
|
||||
// ✅ 正確:使用 formatDate 自動判斷
|
||||
<span>{formatDate(item.created_at)}</span> // → "2024-03-06 08:30:00"
|
||||
<span>{formatDate(item.transaction_date)}</span> // → "2024-03-06"(因為時間為 00:00:00)
|
||||
|
||||
// ✅ 正確:強制只顯示日期
|
||||
<span>{formatDateOnly(item.due_date)}</span> // → "2024-03-06"
|
||||
|
||||
// ❌ 禁止:直接顯示原始 ISO 字串
|
||||
<span>{item.created_at}</span> // → "2024-03-06T08:30:00.000000Z" 😱
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11.7 日期輸入框樣式 (Date Input Style)
|
||||
|
||||
日期輸入框應採用「**左側裝飾圖示 + 右側原生操作**」的配置,以保持視覺一致性並保留瀏覽器原生便利性。
|
||||
|
||||
@@ -805,7 +873,7 @@ import { Input } from "@/Components/ui/input";
|
||||
</div>
|
||||
```
|
||||
|
||||
## 11.7 搜尋選單樣式 (SearchableSelect Style)
|
||||
## 11.8 搜尋選單樣式 (SearchableSelect Style)
|
||||
|
||||
`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。
|
||||
|
||||
@@ -816,7 +884,7 @@ import { Input } from "@/Components/ui/input";
|
||||
/>
|
||||
```
|
||||
|
||||
## 11.8 篩選列規範 (Filter Bar Norms)
|
||||
## 11.9 篩選列規範 (Filter Bar Norms)
|
||||
|
||||
列表頁面的篩選區域(Filter Bar)應遵循以下規範以節省空間並保持層級清晰:
|
||||
|
||||
35
.agents/workflows/now-push.md
Normal file
35
.agents/workflows/now-push.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
description: 將目前的變更提交並推送至指定的遠端分支 (遵守專案規範)
|
||||
---
|
||||
|
||||
# 快速推送工作流 (now-push)
|
||||
|
||||
本工作流旨在規範化 Git 提交與推送流程,確保符合專案的開發規範 (繁體中文、規範前綴) 與發布紀律 (Release Window)。
|
||||
|
||||
## 執行步驟
|
||||
|
||||
1. **讀取規範 (Mandatory)**
|
||||
在執行任何 Git 操作前,**必須** 先讀取 Git 分支管理與開發規範:
|
||||
`view_file` -> [Git SKILL.md](file:///home/mama/projects/star-erp/.agents/skills/git-workflows/SKILL.md)
|
||||
|
||||
2. **檢查與準備**
|
||||
- 執行 `git status` 檢查目前工作目錄。
|
||||
- 根據 **SKILL.md** 的規範撰寫繁體中文提交訊息。
|
||||
|
||||
3. **目標分支安全檢查**
|
||||
- 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
|
||||
- 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`。
|
||||
- **【最嚴格限制】**:`main` 分支的程式碼**只能**, **必須**從 `demo` 分支合併而來。絕對禁止將 `dev` (或 `feature/*`) 直接合併進 `main`。
|
||||
|
||||
4. **執行推送 (Push) 與嚴格合併鏈路**
|
||||
- **若目標為 `dev`**:直接 `git push origin [目前分支]:dev` 或 commit 後 merge 到 dev。
|
||||
- **若目標為 `demo`**:必須先確保變更已在 `dev` 且無衝突,然後 `git checkout demo && git merge dev && git push origin demo`。
|
||||
- **若目標為 `main`**:
|
||||
必須確保變更已經依照順序通過前置環境,嚴格執行以下流程(缺一不可):
|
||||
1. `git checkout dev && git merge [目前分支] && git push origin dev`
|
||||
2. `git checkout demo && git merge dev && git push origin demo`
|
||||
3. `git checkout main && git merge demo && git push origin main`
|
||||
*(就算遭遇衝突,也必須在對應的分支上解衝突,絕對不可略過 `demo` 直接 `dev -> main`)*
|
||||
|
||||
5. **後續同步 (針對 Hotfix)**
|
||||
- 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」:若有從 main 開出來的 hotfix 分支直接併回 main 的例外情況(需使用者明確指示),**必須**同步將 main 分支 merge 回 `demo` 與 `dev` 分支,維持全環境版本一致。
|
||||
@@ -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
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
---
|
||||
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`?
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
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` 進行顯示控制。
|
||||
101
.gitea/workflows/deploy-demo.yaml
Normal file
101
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,101 @@
|
||||
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 tenants:run db:seed --option=\"class=SystemSettingSeeder\" --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
|
||||
94
.gitea/workflows/deploy-prod.yaml
Normal file
94
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
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 tenants:run db:seed --option=\"class=SystemSettingSeeder\" --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,208 +0,0 @@
|
||||
name: Koori-ERP-Deploy-System
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- demo
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# --- 1. Demo 環境部署 (103 本機) ---
|
||||
deploy-demo:
|
||||
if: false # github.ref == 'refs/heads/demo' (暫時停用)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# github-server-url: ${{ github.server_url }} # 自動偵測
|
||||
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 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
|
||||
|
||||
# --- 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:
|
||||
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@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 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
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -18,6 +18,7 @@
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/tenant*
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
@@ -29,3 +30,16 @@ Thumbs.db
|
||||
/docs/presentation
|
||||
docs/Monthly_Report_2026_01.pptx
|
||||
docs/f6_1770350984272.xlsx
|
||||
公共事業費-描述.md
|
||||
.gitignore
|
||||
BOM表自動計算成本.md
|
||||
公共事業費-類別維護.md
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
e2e/screenshots/
|
||||
|
||||
@@ -172,7 +172,6 @@ docker exec -it star-erp-laravel php artisan tinker
|
||||
# 停止容器
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 🧪 開發規範
|
||||
|
||||
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
|
||||
@@ -181,4 +180,3 @@ docker compose down
|
||||
- **多租戶**:
|
||||
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
||||
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
||||
|
||||
|
||||
112
app/Console/Commands/NotifyUtilityFeeStatus.php
Normal file
112
app/Console/Commands/NotifyUtilityFeeStatus.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class NotifyUtilityFeeStatus extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'finance:notify-utility-fees';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '檢查公共事業費狀態並寄送 Email 通知管理員';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info("正在掃描公共事業費狀態...");
|
||||
|
||||
// 1. 更新逾期狀態 (pending -> overdue)
|
||||
\App\Modules\Finance\Models\UtilityFee::where('payment_status', \App\Modules\Finance\Models\UtilityFee::STATUS_PENDING)
|
||||
->whereNotNull('due_date')
|
||||
->where('due_date', '<', now()->startOfDay())
|
||||
->update(['payment_status' => \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE]);
|
||||
|
||||
// 2. 獲取可能需要處理的單據 (pending 或 overdue)
|
||||
$feesToCheck = \App\Modules\Finance\Models\UtilityFee::whereIn('payment_status', [
|
||||
\App\Modules\Finance\Models\UtilityFee::STATUS_PENDING,
|
||||
\App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE
|
||||
])
|
||||
->whereNotNull('due_date')
|
||||
->orderBy('due_date', 'asc')
|
||||
->get();
|
||||
|
||||
if ($feesToCheck->isEmpty()) {
|
||||
$this->info("目前沒有未繳納的公共事業費。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 根據業務規則過濾出今天「真正」需要發信的單據
|
||||
$today = now()->startOfDay();
|
||||
$unpaidFees = $feesToCheck->filter(function ($fee) use ($today) {
|
||||
$dueDate = \Illuminate\Support\Carbon::parse($fee->due_date)->startOfDay();
|
||||
$diffInDays = $today->diffInDays($dueDate, false);
|
||||
|
||||
// 如果已經逾期 (overdue),每天都要發信
|
||||
if ($fee->payment_status === \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果是待繳納 (pending),僅在特定天數發信
|
||||
// 規則:到期前 7 天、3 天、當天 (0 天)
|
||||
return in_array($diffInDays, [7, 3, 0]);
|
||||
});
|
||||
|
||||
if ($unpaidFees->isEmpty()) {
|
||||
$this->info("今日無符合發信條件的公共事業費提醒。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. 讀取系統設定
|
||||
$senderEmail = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_email');
|
||||
$senderPassword = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_password');
|
||||
$recipientEmailsStr = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_recipient_emails');
|
||||
|
||||
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
|
||||
$this->warn("系統設定中缺乏完整的 Email 通知參數,跳過寄送通知。請至「系統設定」->「通知設定」完善資料。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. 動態覆寫應用程式名稱與 SMTP Config
|
||||
$tenantName = tenant('name') ?? config('app.name');
|
||||
config([
|
||||
'app.name' => $tenantName,
|
||||
'mail.mailers.smtp.username' => $senderEmail,
|
||||
'mail.mailers.smtp.password' => $senderPassword,
|
||||
'mail.from.address' => $senderEmail,
|
||||
'mail.from.name' => $tenantName . ' (系統通知)'
|
||||
]);
|
||||
|
||||
// 清理原先可能的 Mailer 實例,確保使用新的 Config
|
||||
\Illuminate\Support\Facades\Mail::purge();
|
||||
|
||||
// 5. 解析收件者並寄送 Email
|
||||
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
|
||||
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
|
||||
|
||||
if (empty($validRecipients)) {
|
||||
$this->warn("無效的收件者 Email 格式,跳過寄送通知。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\PaymentReminderMail($unpaidFees));
|
||||
$this->info("通知郵件已成功寄送至: " . implode(', ', $validRecipients));
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Email 寄送失敗: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class HandleInertiaRequests extends Middleware
|
||||
return [
|
||||
...parent::share($request),
|
||||
'appName' => $appName,
|
||||
'app_version' => config('app.version'),
|
||||
'auth' => [
|
||||
'user' => $user ? [
|
||||
'id' => $user->id,
|
||||
@@ -90,6 +91,12 @@ class HandleInertiaRequests extends Middleware
|
||||
|
||||
return $brandingData;
|
||||
},
|
||||
'notifications' => function () use ($request) {
|
||||
return $request->user() ? [
|
||||
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
] : null;
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
59
app/Mail/PaymentReminderMail.php
Normal file
59
app/Mail/PaymentReminderMail.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PaymentReminderMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $fees;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct($fees)
|
||||
{
|
||||
$this->fees = $fees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$tenantName = tenant('name') ?? '系統';
|
||||
return new Envelope(
|
||||
subject: "【{$tenantName}】公共事業費繳費/逾期通知",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.payment-reminder',
|
||||
with: [
|
||||
'fees' => $this->fees,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
54
app/Mail/TestNotificationMail.php
Normal file
54
app/Mail/TestNotificationMail.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TestNotificationMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$tenantName = tenant('name') ?? '系統';
|
||||
return new Envelope(
|
||||
subject: "【{$tenantName}】電子郵件通知測試",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.test-notification',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Procurement\Models\PurchaseOrder' => '採購單',
|
||||
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
|
||||
'App\Modules\Inventory\Models\Inventory' => '庫存',
|
||||
'App\Modules\Inventory\Models\InventoryTransaction' => '庫存異動紀錄',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
|
||||
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
|
||||
@@ -31,12 +32,17 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
|
||||
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
|
||||
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
|
||||
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
|
||||
];
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$sortBy = $request->input('sort_by', 'created_at');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Sales\Contracts\SalesServiceInterface;
|
||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -13,13 +15,19 @@ class DashboardController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
protected $salesService;
|
||||
protected $productionService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
ProcurementServiceInterface $procurementService,
|
||||
SalesServiceInterface $salesService,
|
||||
ProductionServiceInterface $productionService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
$this->salesService = $salesService;
|
||||
$this->productionService = $productionService;
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -32,6 +40,73 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
$invStats = $this->inventoryService->getDashboardStats();
|
||||
$procStats = $this->procurementService->getDashboardStats();
|
||||
|
||||
// 銷售統計 (本月營收)
|
||||
$thisMonthRevenue = $this->salesService->getThisMonthRevenue();
|
||||
|
||||
// 生產統計 (待核准工單)
|
||||
$pendingProductionCount = $this->productionService->getPendingProductionCount();
|
||||
|
||||
// 生產狀態分佈
|
||||
// 近30日銷售趨勢 (Area Chart)
|
||||
$salesTrend = $this->salesService->getSalesTrend();
|
||||
|
||||
// 本月熱銷商品 Top 5 (Bar Chart)
|
||||
$topSellingItems = $this->salesService->getTopSellingProducts();
|
||||
$productIds = $topSellingItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$productsMap = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
$topSellingProducts = $topSellingItems->map(function ($item) use ($productsMap) {
|
||||
$product = $productsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : $item->product_code,
|
||||
'amount' => (int)$item->total_amount,
|
||||
];
|
||||
});
|
||||
|
||||
// 庫存積壓排行 (Top Inventory Value)
|
||||
$topInventoryValueItems = $this->inventoryService->getTopInventoryValue();
|
||||
$invProductIds = $topInventoryValueItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$invProductsMap = $this->inventoryService->getProductsByIds($invProductIds)->keyBy('id');
|
||||
|
||||
$topInventoryValue = $topInventoryValueItems->map(function ($item) use ($invProductsMap) {
|
||||
$product = $invProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : 'Unknown Product',
|
||||
'code' => $product ? $product->code : '',
|
||||
'value' => (int)$item->total_value,
|
||||
];
|
||||
});
|
||||
|
||||
// 熱銷數量排行 (Top Selling by Quantity)
|
||||
$topSellingQtyItems = $this->salesService->getTopSellingByQuantity();
|
||||
$qtyProductIds = $topSellingQtyItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$qtyProductsMap = $this->inventoryService->getProductsByIds($qtyProductIds)->keyBy('id');
|
||||
|
||||
$topSellingByQuantity = $topSellingQtyItems->map(function ($item) use ($qtyProductsMap) {
|
||||
$product = $qtyProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : $item->product_code,
|
||||
'code' => $item->product_code,
|
||||
'value' => (int)$item->total_quantity,
|
||||
];
|
||||
});
|
||||
|
||||
// 即將過期商品 (Expiring Soon)
|
||||
$expiringItems = $this->inventoryService->getExpiringSoon();
|
||||
$expiringProductIds = $expiringItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$expiringProductsMap = $this->inventoryService->getProductsByIds($expiringProductIds)->keyBy('id');
|
||||
|
||||
$expiringSoon = $expiringItems->map(function ($item) use ($expiringProductsMap) {
|
||||
$product = $expiringProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $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' => [
|
||||
@@ -39,8 +114,18 @@ class DashboardController extends Controller
|
||||
'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,
|
||||
],
|
||||
'abnormalItems' => $invStats['abnormalItems'] ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
private function getGroupedPermissions()
|
||||
{
|
||||
$allPermissions = Permission::orderBy('name')->get();
|
||||
$allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get();
|
||||
$grouped = [];
|
||||
|
||||
foreach ($allPermissions as $permission) {
|
||||
@@ -185,14 +185,21 @@ class RoleController extends Controller
|
||||
'inventory_adjust' => '庫存盤調管理',
|
||||
'inventory_transfer' => '庫存調撥管理',
|
||||
'inventory_report' => '庫存報表',
|
||||
'inventory_traceability' => '批號溯源',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'purchase_returns' => '採購退回管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
'delivery_notes' => '出貨單管理',
|
||||
'recipes' => '配方管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
'account_payables' => '應付帳款',
|
||||
'sales_imports' => '銷售單匯入管理',
|
||||
'sales_orders' => '銷售訂單管理',
|
||||
'store_requisitions' => '門市叫貨申請',
|
||||
'procurement_analysis' => '採購統計分析',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'system' => '系統管理',
|
||||
|
||||
97
app/Modules/Core/Controllers/SystemSettingController.php
Normal file
97
app/Modules/Core/Controllers/SystemSettingController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Core\Models\SystemSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SystemSettingController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示系統設定頁面
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$settings = SystemSetting::all()->groupBy('group');
|
||||
|
||||
return Inertia::render('Admin/Setting/Index', [
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新系統設定
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'settings' => 'required|array',
|
||||
'settings.*.key' => 'required|string|exists:system_settings,key',
|
||||
'settings.*.value' => 'nullable',
|
||||
]);
|
||||
|
||||
foreach ($validated['settings'] as $item) {
|
||||
SystemSetting::where('key', $item['key'])->update([
|
||||
'value' => $item['value']
|
||||
]);
|
||||
}
|
||||
|
||||
// 清除記憶體快取,確保後續讀取拿到最新值
|
||||
SystemSetting::clearCache();
|
||||
|
||||
return redirect()->back()->with('success', '系統設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 測試發送通知信
|
||||
*/
|
||||
public function testNotification(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'settings' => 'required|array',
|
||||
'settings.*.key' => 'required|string',
|
||||
'settings.*.value' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$settings = collect($validated['settings'])->pluck('value', 'key');
|
||||
|
||||
$senderEmail = $settings['notification.utility_fee_sender_email'] ?? null;
|
||||
$senderPassword = $settings['notification.utility_fee_sender_password'] ?? null;
|
||||
$recipientEmailsStr = $settings['notification.utility_fee_recipient_emails'] ?? null;
|
||||
|
||||
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
|
||||
return back()->with('error', '請先填寫完整發信帳號、密碼及收件者信箱。');
|
||||
}
|
||||
|
||||
// 動態覆寫應用程式名稱與 SMTP Config
|
||||
$tenantName = tenant('name') ?? config('app.name');
|
||||
config([
|
||||
'app.name' => $tenantName,
|
||||
'mail.mailers.smtp.username' => $senderEmail,
|
||||
'mail.mailers.smtp.password' => $senderPassword,
|
||||
'mail.from.address' => $senderEmail,
|
||||
'mail.from.name' => $tenantName . ' (系統通知)'
|
||||
]);
|
||||
|
||||
// 清理原先可能的 Mailer 實例,確保使用新的 Config
|
||||
\Illuminate\Support\Facades\Mail::purge();
|
||||
|
||||
// 解析收件者
|
||||
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
|
||||
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
|
||||
|
||||
if (empty($validRecipients)) {
|
||||
return back()->with('error', '無效的收件者 Email 格式。');
|
||||
}
|
||||
|
||||
try {
|
||||
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\TestNotificationMail());
|
||||
return back()->with('success', '測試信件已成功發送,請檢查收件匣。');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', '測試發信失敗: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ class UserController extends Controller
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
$search = $request->input('search');
|
||||
|
||||
61
app/Modules/Core/Models/SystemSetting.php
Normal file
61
app/Modules/Core/Models/SystemSetting.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SystemSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'value',
|
||||
'type',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* 同請求內的記憶體快取,避免重複查詢 DB
|
||||
* PHP 請求結束後自動釋放,無需額外處理失效
|
||||
*/
|
||||
protected static array $cache = [];
|
||||
|
||||
/**
|
||||
* 取得特定設定值(含記憶體快取)
|
||||
*/
|
||||
public static function getVal(string $key, $default = null)
|
||||
{
|
||||
if (array_key_exists($key, static::$cache)) {
|
||||
return static::$cache[$key];
|
||||
}
|
||||
|
||||
$setting = self::where('key', $key)->first();
|
||||
|
||||
if (!$setting) {
|
||||
static::$cache[$key] = $default;
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = $setting->value;
|
||||
|
||||
// 根據 type 進行類別轉換
|
||||
$resolved = match ($setting->type) {
|
||||
'integer', 'number' => (int) $value,
|
||||
'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
||||
'json', 'array' => json_decode($value, true),
|
||||
default => $value,
|
||||
};
|
||||
|
||||
static::$cache[$key] = $resolved;
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除記憶體快取(儲存設定後應呼叫)
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
static::$cache = [];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Modules\Core\Controllers\ProfileController;
|
||||
use App\Modules\Core\Controllers\RoleController;
|
||||
use App\Modules\Core\Controllers\UserController;
|
||||
use App\Modules\Core\Controllers\ActivityLogController;
|
||||
use App\Modules\Core\Controllers\SystemSettingController;
|
||||
|
||||
// 登入/登出路由
|
||||
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
||||
@@ -14,6 +15,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');
|
||||
|
||||
@@ -51,5 +57,11 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
|
||||
});
|
||||
|
||||
Route::middleware('permission:system.settings.view')->group(function () {
|
||||
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
|
||||
Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update');
|
||||
Route::post('/settings/test-notification', [SystemSettingController::class, 'testNotification'])->name('settings.test-notification');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ class CoreService implements CoreServiceInterface
|
||||
*/
|
||||
public function getUsersByIds(array $ids): Collection
|
||||
{
|
||||
return User::whereIn('id', $ids)->get();
|
||||
return User::select('id', 'name')->whereIn('id', $ids)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ class CoreService implements CoreServiceInterface
|
||||
*/
|
||||
public function getAllUsers(): Collection
|
||||
{
|
||||
return User::all();
|
||||
return User::select('id', 'name')->get();
|
||||
}
|
||||
|
||||
public function ensureSystemUserExists()
|
||||
|
||||
160
app/Modules/Finance/Controllers/AccountPayableController.php
Normal file
160
app/Modules/Finance/Controllers/AccountPayableController.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Finance\Models\AccountPayable;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
|
||||
class AccountPayableController extends Controller
|
||||
{
|
||||
protected $procurementService;
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(
|
||||
ProcurementServiceInterface $procurementService,
|
||||
InventoryServiceInterface $inventoryService
|
||||
) {
|
||||
$this->procurementService = $procurementService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = AccountPayable::with(['creator']);
|
||||
|
||||
// 關鍵字搜尋 (單號、供應商名稱)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
|
||||
// 透過 ProcurementService 查詢符合關鍵字的 Vendor IDs
|
||||
$matchedVendors = $this->procurementService->searchVendors($search);
|
||||
$vendorIds = $matchedVendors->pluck('id')->toArray();
|
||||
|
||||
$query->where(function ($q) use ($search, $vendorIds) {
|
||||
$q->where('document_number', 'like', "%{$search}%");
|
||||
if (!empty($vendorIds)) {
|
||||
$q->orWhereIn('vendor_id', $vendorIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態過濾
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// 供應商過濾
|
||||
if ($request->filled('vendor_id') && $request->vendor_id !== 'all') {
|
||||
$query->where('vendor_id', $request->vendor_id);
|
||||
}
|
||||
|
||||
// 日期區間過濾
|
||||
if ($request->filled('date_start')) {
|
||||
$query->where('due_date', '>=', $request->date_start);
|
||||
}
|
||||
if ($request->filled('date_end')) {
|
||||
$query->where('due_date', '<=', $request->date_end);
|
||||
}
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$payables = $query->latest()->paginate($perPage)->withQueryString();
|
||||
|
||||
// Manual Hydration for Vendors
|
||||
$allVendorIds = collect($payables->items())->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendorsMap = $this->procurementService->getVendorsByIds($allVendorIds)->keyBy('id');
|
||||
|
||||
$payables->getCollection()->transform(function ($item) use ($vendorsMap) {
|
||||
$item->vendor = $vendorsMap->get($item->vendor_id);
|
||||
return $item;
|
||||
});
|
||||
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
return Inertia::render('AccountPayable/Index', [
|
||||
'payables' => $payables,
|
||||
'filters' => $request->all(['search', 'status', 'vendor_id', 'date_start', 'date_end', 'per_page']),
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(AccountPayable $accountPayable)
|
||||
{
|
||||
$accountPayable->load(['creator']);
|
||||
|
||||
if ($accountPayable->vendor_id) {
|
||||
$accountPayable->vendor = $this->procurementService->getVendorsByIds([$accountPayable->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 嘗試加載來源單據資訊 (目前支援 goods_receipt)
|
||||
$sourceDocumentCode = null;
|
||||
if ($accountPayable->source_document_type === 'goods_receipt') {
|
||||
$receiptData = app(\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class)
|
||||
->getGoodsReceiptData($accountPayable->source_document_id);
|
||||
if ($receiptData) {
|
||||
$sourceDocumentCode = $receiptData['code'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('AccountPayable/Show', [
|
||||
// 將 model 轉換成 array 加入額外資訊
|
||||
'payable' => array_merge($accountPayable->toArray(), ['source_document_code' => $sourceDocumentCode]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新發票資訊
|
||||
*/
|
||||
public function updateInvoice(Request $request, AccountPayable $accountPayable)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'invoice_number' => 'nullable|string|max:50',
|
||||
'invoice_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$accountPayable->update([
|
||||
'invoice_number' => $validated['invoice_number'],
|
||||
'invoice_date' => $validated['invoice_date'],
|
||||
]);
|
||||
|
||||
return back()->with('success', '發票資訊已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 標記已付款
|
||||
*/
|
||||
public function pay(Request $request, AccountPayable $accountPayable)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'payment_method' => 'required|string|max:50',
|
||||
'paid_at' => 'required|date',
|
||||
'payment_note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($accountPayable->status === AccountPayable::STATUS_PAID) {
|
||||
return back()->with('error', '該帳款已經標記為已付款');
|
||||
}
|
||||
|
||||
$accountPayable->update([
|
||||
'status' => AccountPayable::STATUS_PAID,
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'paid_at' => $validated['paid_at'],
|
||||
'payment_note' => $validated['payment_note'],
|
||||
]);
|
||||
|
||||
return back()->with('success', '帳款已成功標記為已付款');
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,11 @@ class AccountingReportController extends Controller
|
||||
$allRecords = $reportData['records'];
|
||||
|
||||
// 3. Manual Pagination
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$page = $request->input('page', 1);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
@@ -65,14 +69,25 @@ class AccountingReportController extends Controller
|
||||
}
|
||||
|
||||
$exportData = $allRecords->map(function ($record) {
|
||||
$taxAmount = (float)($record['tax_amount'] ?? 0);
|
||||
$totalAmount = (float)($record['amount'] ?? 0);
|
||||
$untaxedAmount = $totalAmount - $taxAmount;
|
||||
|
||||
return [
|
||||
$record['date'],
|
||||
$record['source'],
|
||||
$record['category'],
|
||||
$record['item'],
|
||||
$record['reference'],
|
||||
$record['invoice_number'],
|
||||
$record['amount'],
|
||||
$record['invoice_date'] ?? '-',
|
||||
$record['invoice_number'] ?? '-',
|
||||
$untaxedAmount,
|
||||
$taxAmount,
|
||||
$totalAmount,
|
||||
$record['payment_method'] ?? '-',
|
||||
$record['payment_note'] ?? '-',
|
||||
$record['remarks'] ?? '-',
|
||||
$record['status'] ?? '-',
|
||||
];
|
||||
});
|
||||
|
||||
@@ -87,7 +102,11 @@ class AccountingReportController extends Controller
|
||||
// BOM for Excel compatibility with UTF-8
|
||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
|
||||
fputcsv($file, [
|
||||
'日期', '來源', '類別', '項目', '參考單號',
|
||||
'發票日期', '發票號碼', '未稅金額', '稅額', '總金額',
|
||||
'付款方式', '付款備註', '內部備註', '狀態'
|
||||
]);
|
||||
|
||||
foreach ($exportData as $row) {
|
||||
fputcsv($file, $row);
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Finance\Models\UtilityFee;
|
||||
use App\Modules\Finance\Models\UtilityFeeAttachment;
|
||||
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UtilityFeeController extends Controller
|
||||
@@ -34,13 +36,16 @@ class UtilityFeeController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'transaction_date' => 'required|date',
|
||||
'transaction_date' => 'nullable|date',
|
||||
'due_date' => 'required|date',
|
||||
'category' => 'required|string|max:255',
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'invoice_number' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['payment_status'] = $this->determineStatus($validated);
|
||||
|
||||
$fee = UtilityFee::create($validated);
|
||||
|
||||
activity()
|
||||
@@ -55,13 +60,16 @@ class UtilityFeeController extends Controller
|
||||
public function update(Request $request, UtilityFee $utility_fee)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'transaction_date' => 'required|date',
|
||||
'transaction_date' => 'nullable|date',
|
||||
'due_date' => 'required|date',
|
||||
'category' => 'required|string|max:255',
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'invoice_number' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['payment_status'] = $this->determineStatus($validated);
|
||||
|
||||
$utility_fee->update($validated);
|
||||
|
||||
activity()
|
||||
@@ -73,6 +81,22 @@ class UtilityFeeController extends Controller
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定繳費狀態
|
||||
*/
|
||||
private function determineStatus(array $data): string
|
||||
{
|
||||
if (!empty($data['transaction_date'])) {
|
||||
return UtilityFee::STATUS_PAID;
|
||||
}
|
||||
|
||||
if (!empty($data['due_date']) && now()->startOfDay()->gt(\Illuminate\Support\Carbon::parse($data['due_date']))) {
|
||||
return UtilityFee::STATUS_OVERDUE;
|
||||
}
|
||||
|
||||
return UtilityFee::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function destroy(UtilityFee $utility_fee)
|
||||
{
|
||||
activity()
|
||||
@@ -81,8 +105,82 @@ class UtilityFeeController extends Controller
|
||||
->event('deleted')
|
||||
->log('deleted');
|
||||
|
||||
// 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理)
|
||||
foreach ($utility_fee->attachments as $attachment) {
|
||||
Storage::disk('public')->delete($attachment->file_path);
|
||||
}
|
||||
|
||||
$utility_fee->delete();
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取附件列表
|
||||
*/
|
||||
public function attachments(UtilityFee $utility_fee)
|
||||
{
|
||||
return response()->json([
|
||||
'attachments' => $utility_fee->attachments()->orderBy('created_at', 'desc')->get()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上傳附件
|
||||
*/
|
||||
public function uploadAttachment(Request $request, UtilityFee $utility_fee)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp,pdf|max:2048', // 2MB
|
||||
]);
|
||||
|
||||
// 檢查數量限制 (最多 3 張)
|
||||
if ($utility_fee->attachments()->count() >= 3) {
|
||||
return response()->json(['message' => '附件數量已達上限 (最多 3 個)'], 422);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$path = $file->store("utility-fee-attachments/{$utility_fee->id}", 'public');
|
||||
|
||||
$attachment = $utility_fee->attachments()->create([
|
||||
'file_path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('attachment_uploaded')
|
||||
->log("uploaded attachment: {$attachment->original_name}");
|
||||
|
||||
return response()->json([
|
||||
'message' => '上傳成功',
|
||||
'attachment' => $attachment
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除附件
|
||||
*/
|
||||
public function deleteAttachment(UtilityFee $utility_fee, UtilityFeeAttachment $attachment)
|
||||
{
|
||||
// 確保附件屬於該費用
|
||||
if ($attachment->utility_fee_id !== $utility_fee->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($attachment->file_path);
|
||||
|
||||
$attachment->delete();
|
||||
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('attachment_deleted')
|
||||
->log("deleted attachment: {$attachment->original_name}");
|
||||
|
||||
return response()->json(['message' => '刪除成功']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ class FinanceServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
\Illuminate\Support\Facades\Event::listen(
|
||||
\App\Modules\Inventory\Events\GoodsReceiptApprovedEvent::class,
|
||||
\App\Modules\Finance\Listeners\CreateAccountPayableFromGoodsReceipt::class
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Listeners;
|
||||
|
||||
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
|
||||
use App\Modules\Finance\Services\AccountPayableService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CreateAccountPayableFromGoodsReceipt
|
||||
{
|
||||
protected AccountPayableService $accountPayableService;
|
||||
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct(AccountPayableService $accountPayableService)
|
||||
{
|
||||
$this->accountPayableService = $accountPayableService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(GoodsReceiptApprovedEvent $event): void
|
||||
{
|
||||
try {
|
||||
// 目前使用系統預設 User ID 或 0 作為自動生成的建立者,若能從 event 取得更好
|
||||
$userId = auth()->id() ?? 1; // 假設 1 為系統管理員或預設使用者
|
||||
|
||||
$this->accountPayableService->createFromGoodsReceipt($event->goodsReceiptId, $userId);
|
||||
|
||||
Log::info("已成功為進貨單 ID: {$event->goodsReceiptId} 建立應付帳款");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("建立應付帳款失敗 (進貨單 ID: {$event->goodsReceiptId}): " . $e->getMessage());
|
||||
// 根據需求決定是否拋出 exception 或只記錄 log
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/Modules/Finance/Models/AccountPayable.php
Normal file
54
app/Modules/Finance/Models/AccountPayable.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AccountPayable extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PARTIALLY_PAID = 'partially_paid';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'source_document_type',
|
||||
'source_document_id',
|
||||
'document_number',
|
||||
'total_amount',
|
||||
'tax_amount',
|
||||
'status',
|
||||
'due_date',
|
||||
'invoice_number',
|
||||
'invoice_date',
|
||||
'paid_at',
|
||||
'payment_method',
|
||||
'payment_note',
|
||||
'remarks',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'due_date' => 'date',
|
||||
'invoice_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
// vendor 關聯移至 service (跨模組)
|
||||
|
||||
/**
|
||||
* 關聯:建立者
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Core\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -7,29 +7,47 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UtilityFee extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 此公共事業費的附件
|
||||
*/
|
||||
public function attachments()
|
||||
{
|
||||
return $this->hasMany(UtilityFeeAttachment::class);
|
||||
}
|
||||
|
||||
// 狀態常數
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PAID = 'paid';
|
||||
const STATUS_OVERDUE = 'overdue';
|
||||
|
||||
protected $fillable = [
|
||||
'transaction_date',
|
||||
'due_date',
|
||||
'category',
|
||||
'amount',
|
||||
'payment_status',
|
||||
'invoice_number',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'transaction_date' => 'date',
|
||||
'transaction_date' => 'date:Y-m-d',
|
||||
'due_date' => 'date:Y-m-d',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$activity->properties = $activity->properties->put('snapshot', [
|
||||
'transaction_date' => $this->transaction_date->format('Y-m-d'),
|
||||
$snapshot = [
|
||||
'transaction_date' => $this->transaction_date?->format('Y-m-d'),
|
||||
'due_date' => $this->due_date?->format('Y-m-d'),
|
||||
'category' => $this->category,
|
||||
'amount' => $this->amount,
|
||||
'payment_status' => $this->payment_status,
|
||||
'invoice_number' => $this->invoice_number,
|
||||
]);
|
||||
];
|
||||
$activity->properties = $activity->properties->put('snapshot', $snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UtilityFeeAttachment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'utility_fee_id',
|
||||
'file_path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
];
|
||||
|
||||
protected $appends = ['url'];
|
||||
|
||||
/**
|
||||
* 附件所属的公共事業費
|
||||
*/
|
||||
public function utilityFee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UtilityFee::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取附件的全路徑 URL
|
||||
*/
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return tenant_asset($this->file_path);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,23 @@ use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Finance\Controllers\UtilityFeeController;
|
||||
use App\Modules\Finance\Controllers\AccountingReportController;
|
||||
|
||||
use App\Modules\Finance\Controllers\AccountPayableController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
// 應付帳款
|
||||
Route::group(['prefix' => 'finance'], function () {
|
||||
Route::middleware('permission:account_payables.view')->group(function () {
|
||||
Route::get('/account-payables', [AccountPayableController::class, 'index'])->name('account-payables.index');
|
||||
Route::get('/account-payables/{accountPayable}', [AccountPayableController::class, 'show'])->name('account-payables.show');
|
||||
});
|
||||
Route::middleware('permission:account_payables.edit')->group(function () {
|
||||
Route::post('/account-payables/{accountPayable}/invoice', [AccountPayableController::class, 'updateInvoice'])->name('account-payables.invoice');
|
||||
});
|
||||
Route::middleware('permission:account_payables.pay')->group(function () {
|
||||
Route::post('/account-payables/{accountPayable}/pay', [AccountPayableController::class, 'pay'])->name('account-payables.pay');
|
||||
});
|
||||
});
|
||||
|
||||
// 公共事業費管理
|
||||
Route::middleware('permission:utility_fees.view')->group(function () {
|
||||
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
|
||||
@@ -14,6 +30,11 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
Route::middleware('permission:utility_fees.edit')->group(function () {
|
||||
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update');
|
||||
|
||||
// 附件管理 (Ajax)
|
||||
Route::get('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'attachments'])->name('utility-fees.attachments');
|
||||
Route::post('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'uploadAttachment'])->name('utility-fees.upload-attachment');
|
||||
Route::delete('/utility-fees/{utility_fee}/attachments/{attachment}', [UtilityFeeController::class, 'deleteAttachment'])->name('utility-fees.delete-attachment');
|
||||
});
|
||||
Route::middleware('permission:utility_fees.delete')->group(function () {
|
||||
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
|
||||
|
||||
86
app/Modules/Finance/Services/AccountPayableService.php
Normal file
86
app/Modules/Finance/Services/AccountPayableService.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Services;
|
||||
|
||||
use App\Modules\Finance\Models\AccountPayable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface;
|
||||
|
||||
class AccountPayableService
|
||||
{
|
||||
protected GoodsReceiptServiceInterface $goodsReceiptService;
|
||||
|
||||
public function __construct(GoodsReceiptServiceInterface $goodsReceiptService)
|
||||
{
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據進貨單建立應付帳款
|
||||
*
|
||||
* @param int $goodsReceiptId
|
||||
* @param int $userId 執行操作的使用者 ID
|
||||
* @return AccountPayable
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createFromGoodsReceipt(int $goodsReceiptId, int $userId): AccountPayable
|
||||
{
|
||||
// 透過 Contract 取得 Inventory 模組的資料,避免直接依賴 Model
|
||||
$receiptData = $this->goodsReceiptService->getGoodsReceiptData($goodsReceiptId);
|
||||
|
||||
if (!$receiptData) {
|
||||
throw new \Exception("找不到對應的進貨單資料 (ID: {$goodsReceiptId})");
|
||||
}
|
||||
|
||||
// 檢查是否已經建立過(密等性)
|
||||
$existingAp = AccountPayable::where('source_document_type', 'goods_receipt')
|
||||
->where('source_document_id', $goodsReceiptId)
|
||||
->first();
|
||||
|
||||
if ($existingAp) {
|
||||
return $existingAp;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($receiptData, $userId) {
|
||||
$ap = AccountPayable::create([
|
||||
'vendor_id' => $receiptData['vendor_id'],
|
||||
'source_document_type' => 'goods_receipt',
|
||||
'source_document_id' => $receiptData['id'],
|
||||
'document_number' => $this->generateApNumber(),
|
||||
'total_amount' => collect($receiptData['items'] ?? [])->sum('total_amount'),
|
||||
'tax_amount' => 0, // 假設後續會實作稅額計算,目前預設為 0
|
||||
'status' => AccountPayable::STATUS_PENDING,
|
||||
// 設定應付日期,預設為進貨後天數 (由系統設定決定,預設 30 天)
|
||||
'due_date' => now()->addDays(\App\Modules\Core\Models\SystemSetting::getVal('finance.ap_payment_days', 30))->toDateString(),
|
||||
'created_by' => $userId,
|
||||
'remarks' => "由進貨單 {$receiptData['code']} 自動生成",
|
||||
]);
|
||||
|
||||
return $ap;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生應付帳款單號
|
||||
*/
|
||||
protected function generateApNumber(): string
|
||||
{
|
||||
$prefix = 'AP-' . date('Ymd') . '-';
|
||||
$lastPrefix = "{$prefix}%";
|
||||
|
||||
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
||||
->orderBy('document_number', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$latest) {
|
||||
return $prefix . '01';
|
||||
}
|
||||
|
||||
$parts = explode('-', $latest->document_number);
|
||||
$lastNumber = intval(end($parts));
|
||||
$newNumber = str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT);
|
||||
|
||||
return $prefix . $newNumber;
|
||||
}
|
||||
}
|
||||
@@ -19,23 +19,48 @@ class FinanceService implements FinanceServiceInterface
|
||||
|
||||
public function getAccountingReportData(string $start, string $end): array
|
||||
{
|
||||
// 1. 獲取採購單資料
|
||||
$purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end)
|
||||
->map(function ($po) {
|
||||
return [
|
||||
'id' => 'PO-' . $po->id,
|
||||
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
|
||||
'source' => '採購單',
|
||||
'category' => '進貨支出',
|
||||
'item' => $po->vendor->name ?? '未知廠商',
|
||||
'reference' => $po->code,
|
||||
'invoice_number' => $po->invoice_number,
|
||||
'amount' => (float)$po->grand_total,
|
||||
];
|
||||
});
|
||||
// 1. 獲取應付帳款資料 (已付款)
|
||||
$accountPayables = \App\Modules\Finance\Models\AccountPayable::where('status', \App\Modules\Finance\Models\AccountPayable::STATUS_PAID)
|
||||
->whereNotNull('paid_at')
|
||||
->whereBetween('paid_at', [$start, $end])
|
||||
->get();
|
||||
|
||||
// 2. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date)
|
||||
$utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end])
|
||||
// 取得供應商資料 (Manual Hydration)
|
||||
$vendorIds = $accountPayables->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendorsMap = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
|
||||
|
||||
// 付款方式對映
|
||||
$paymentMethodMap = [
|
||||
'cash' => '現金',
|
||||
'bank_transfer' => '銀行轉帳',
|
||||
'check' => '支票',
|
||||
'credit_card' => '信用卡',
|
||||
];
|
||||
|
||||
$payableRecords = $accountPayables->map(function ($ap) use ($vendorsMap, $paymentMethodMap) {
|
||||
$vendorName = isset($vendorsMap[$ap->vendor_id]) ? $vendorsMap[$ap->vendor_id]->name : '未知廠商';
|
||||
$mappedPaymentMethod = $paymentMethodMap[$ap->payment_method] ?? $ap->payment_method;
|
||||
return [
|
||||
'id' => 'AP-' . $ap->id,
|
||||
'date' => Carbon::parse($ap->paid_at)->timezone(config('app.timezone'))->toDateString(),
|
||||
'source' => '應付帳款',
|
||||
'category' => '進貨支出',
|
||||
'item' => $vendorName,
|
||||
'reference' => $ap->document_number,
|
||||
'invoice_date' => $ap->invoice_date ? $ap->invoice_date->format('Y-m-d') : null,
|
||||
'invoice_number' => $ap->invoice_number,
|
||||
'amount' => (float)$ap->total_amount,
|
||||
'tax_amount' => (float)$ap->tax_amount,
|
||||
'status' => $ap->status,
|
||||
'payment_method' => $mappedPaymentMethod,
|
||||
'payment_note' => $ap->payment_note,
|
||||
'remarks' => $ap->remarks,
|
||||
];
|
||||
});
|
||||
|
||||
// 2. 獲取公共事業費 (已繳費)
|
||||
$utilityFees = UtilityFee::where('payment_status', UtilityFee::STATUS_PAID)
|
||||
->whereBetween('transaction_date', [$start, $end])
|
||||
->get()
|
||||
->map(function ($fee) {
|
||||
return [
|
||||
@@ -45,12 +70,18 @@ class FinanceService implements FinanceServiceInterface
|
||||
'category' => $fee->category,
|
||||
'item' => $fee->description ?: $fee->category,
|
||||
'reference' => '-',
|
||||
'invoice_date' => null,
|
||||
'invoice_number' => $fee->invoice_number,
|
||||
'amount' => (float)$fee->amount,
|
||||
'tax_amount' => 0.0,
|
||||
'status' => $fee->payment_status,
|
||||
'payment_method' => null,
|
||||
'payment_note' => null,
|
||||
'remarks' => $fee->description,
|
||||
];
|
||||
});
|
||||
|
||||
$allRecords = $purchaseOrders->concat($utilityFees)
|
||||
$allRecords = $payableRecords->concat($utilityFees)
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
@@ -58,7 +89,7 @@ class FinanceService implements FinanceServiceInterface
|
||||
'records' => $allRecords,
|
||||
'summary' => [
|
||||
'total_amount' => $allRecords->sum('amount'),
|
||||
'purchase_total' => $purchaseOrders->sum('amount'),
|
||||
'payable_total' => $payableRecords->sum('amount'),
|
||||
'utility_total' => $utilityFees->sum('amount'),
|
||||
'record_count' => $allRecords->count(),
|
||||
]
|
||||
@@ -67,7 +98,7 @@ class FinanceService implements FinanceServiceInterface
|
||||
|
||||
public function getUtilityFees(array $filters)
|
||||
{
|
||||
$query = UtilityFee::query();
|
||||
$query = UtilityFee::withCount('attachments');
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
@@ -94,7 +125,13 @@ class FinanceService implements FinanceServiceInterface
|
||||
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
|
||||
return $query->paginate($filters['per_page'] ?? 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) ($filters['per_page'] ?? $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getUniqueCategories(): Collection
|
||||
|
||||
161
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
161
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?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. 查找倉庫
|
||||
$warehouseCode = $data['warehouse_code'];
|
||||
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||
|
||||
if ($warehouses->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||
]);
|
||||
}
|
||||
$warehouseId = $warehouses->first()->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,
|
||||
null,
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
$order->id
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
157
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
157
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?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. 查找倉庫
|
||||
$warehouseCode = $data['warehouse_code'];
|
||||
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||
|
||||
if ($warehouses->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||
]);
|
||||
}
|
||||
$warehouseId = $warehouses->first()->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,
|
||||
null,
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
$order->id
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class InventorySyncController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供外部 POS 查詢指定倉庫的商品庫存餘額
|
||||
*
|
||||
* @param string $warehouseCode
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(string $warehouseCode): JsonResponse
|
||||
{
|
||||
// 透過 Service 調用跨模組庫存查詢功能
|
||||
$inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode);
|
||||
|
||||
// 若回傳 null,表示尋無此倉庫代碼
|
||||
if (is_null($inventoryData)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "Warehouse with code '{$warehouseCode}' not found.",
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 以 JSON 格式回傳組合好的商品庫存列表
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'warehouse_code' => $warehouseCode,
|
||||
'data' => $inventoryData->map(function ($item) {
|
||||
return [
|
||||
'external_pos_id' => $item->external_pos_id,
|
||||
'product_code' => $item->product_code,
|
||||
'product_name' => $item->product_name,
|
||||
'quantity' => (float) $item->total_quantity,
|
||||
];
|
||||
})
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@@ -3,107 +3,58 @@
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use App\Modules\Integration\Models\SalesOrderItem;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
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 $inventoryService;
|
||||
protected $syncOrderAction;
|
||||
|
||||
public function __construct(InventoryService $inventoryService)
|
||||
public function __construct(SyncOrderAction $syncOrderAction)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->syncOrderAction = $syncOrderAction;
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
/**
|
||||
* 接收並同步外部交易訂單
|
||||
*
|
||||
* @param SyncOrderRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(SyncOrderRequest $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'external_order_id' => 'required|string|unique:sales_orders,external_order_id',
|
||||
'warehouse' => 'nullable|string',
|
||||
'warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
'items' => 'required|array',
|
||||
'items.*.pos_product_id' => 'required|string',
|
||||
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||
'items.*.price' => 'required|numeric',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request) {
|
||||
// 1. Create Order
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $request->external_order_id,
|
||||
'status' => 'completed',
|
||||
'payment_method' => $request->payment_method ?? 'cash',
|
||||
'total_amount' => 0, // Will calculate
|
||||
'sold_at' => $request->sold_at ?? now(),
|
||||
'raw_payload' => $request->all(),
|
||||
]);
|
||||
// 所有驗證皆已透過 SyncOrderRequest 自動處理
|
||||
// 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動)
|
||||
$result = $this->syncOrderAction->execute($request->validated());
|
||||
|
||||
// Find Warehouse (Default to "銷售倉庫")
|
||||
$warehouseId = $request->warehouse_id;
|
||||
|
||||
if (empty($warehouseId)) {
|
||||
$warehouseName = $request->warehouse ?: '銷售倉庫';
|
||||
$warehouse = Warehouse::firstOrCreate(['name' => $warehouseName], [
|
||||
'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))),
|
||||
'type' => 'system_sales',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$warehouseId = $warehouse->id;
|
||||
}
|
||||
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||
|
||||
$totalAmount = 0;
|
||||
return response()->json([
|
||||
'message' => $result['message'],
|
||||
'order_id' => $result['order_id'] ?? null,
|
||||
], $statusCode);
|
||||
|
||||
foreach ($request->items as $itemData) {
|
||||
// Find product by external ID (Strict Check)
|
||||
$product = Product::where('external_pos_id', $itemData['pos_product_id'])->first();
|
||||
|
||||
if (!$product) {
|
||||
throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first.");
|
||||
}
|
||||
|
||||
$qty = $itemData['qty'];
|
||||
$price = $itemData['price'];
|
||||
$lineTotal = $qty * $price;
|
||||
$totalAmount += $lineTotal;
|
||||
|
||||
// 2. Create Order Item
|
||||
SalesOrderItem::create([
|
||||
'sales_order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name, // Snapshot name
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $lineTotal,
|
||||
]);
|
||||
|
||||
// 3. Deduct Stock (Force negative allowed for POS orders)
|
||||
$this->inventoryService->decreaseStock(
|
||||
$product->id,
|
||||
$warehouseId,
|
||||
$qty,
|
||||
"POS Order: " . $order->external_order_id,
|
||||
true // Force = true
|
||||
);
|
||||
}
|
||||
|
||||
$order->update(['total_amount' => $totalAmount]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Order synced and stock deducted successfully',
|
||||
'order_id' => $order->id,
|
||||
], 201);
|
||||
});
|
||||
} 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(), 'payload' => $request->all()]);
|
||||
return response()->json(['message' => 'Sync failed: ' . $e->getMessage()], 400);
|
||||
// 系統層級的錯誤
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Inventory\Services\ProductService;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProductSyncController extends Controller
|
||||
{
|
||||
protected $productService;
|
||||
|
||||
public function __construct(ProductService $productService)
|
||||
public function __construct(ProductServiceInterface $productService)
|
||||
{
|
||||
$this->productService = $productService;
|
||||
}
|
||||
@@ -19,12 +19,17 @@ class ProductSyncController extends Controller
|
||||
public function upsert(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'external_pos_id' => 'required|string',
|
||||
'name' => 'required|string',
|
||||
'price' => 'nullable|numeric',
|
||||
'barcode' => 'nullable|string',
|
||||
'category' => 'nullable|string',
|
||||
'unit' => 'nullable|string',
|
||||
'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',
|
||||
]);
|
||||
|
||||
@@ -40,7 +45,9 @@ class ProductSyncController extends Controller
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
||||
return response()->json(['message' => 'Sync failed'], 500);
|
||||
return response()->json([
|
||||
'message' => 'Sync failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
58
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?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');
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$orders = $query->paginate($perPage)
|
||||
->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ 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
|
||||
@@ -11,10 +14,16 @@ class IntegrationServiceProvider extends ServiceProvider
|
||||
public function boot()
|
||||
{
|
||||
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
||||
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
||||
|
||||
// Register Middleware Alias
|
||||
// 註冊 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()
|
||||
|
||||
@@ -16,6 +16,8 @@ class SalesOrder extends Model
|
||||
'total_amount',
|
||||
'sold_at',
|
||||
'raw_payload',
|
||||
'source',
|
||||
'source_label',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
35
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
35
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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_code' => 'required|string',
|
||||
'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',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
36
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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_code' => 'required|string',
|
||||
'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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Integration\Controllers\ProductSyncController;
|
||||
use App\Modules\Integration\Controllers\OrderSyncController;
|
||||
use App\Modules\Integration\Controllers\VendingOrderSyncController;
|
||||
use App\Modules\Integration\Controllers\InventorySyncController;
|
||||
|
||||
Route::prefix('api/v1/integration')
|
||||
->middleware(['api', 'integration.tenant', 'auth:sanctum']) // integration.tenant middleware to identify tenant
|
||||
->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']);
|
||||
Route::get('inventory/{warehouse_code}', [InventorySyncController::class, 'show']);
|
||||
});
|
||||
|
||||
13
app/Modules/Integration/Routes/web.php
Normal file
13
app/Modules/Integration/Routes/web.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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::middleware('permission:sales_orders.view')->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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Contracts;
|
||||
|
||||
interface GoodsReceiptServiceInterface
|
||||
{
|
||||
/**
|
||||
* 獲取指定的進貨單資訊
|
||||
*
|
||||
* @param int $goodsReceiptId
|
||||
* @return array|null 返回進貨單的純陣列資料,若找不到則回傳 null
|
||||
*/
|
||||
public function getGoodsReceiptData(int $goodsReceiptId): ?array;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ interface InventoryServiceInterface
|
||||
* @param string|null $slot
|
||||
* @return void
|
||||
*/
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void;
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void;
|
||||
|
||||
/**
|
||||
* Get all active warehouses.
|
||||
@@ -40,6 +40,14 @@ interface InventoryServiceInterface
|
||||
*/
|
||||
public function getProductsByIds(array $ids);
|
||||
|
||||
/**
|
||||
* Get multiple warehouses by their codes.
|
||||
*
|
||||
* @param array $codes
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getWarehousesByCodes(array $codes);
|
||||
|
||||
/**
|
||||
* Search products by name.
|
||||
*
|
||||
@@ -123,7 +131,7 @@ interface InventoryServiceInterface
|
||||
* @param int $perPage 每頁筆數
|
||||
* @return array
|
||||
*/
|
||||
public function getStockQueryData(array $filters = [], int $perPage = 10): array;
|
||||
public function getStockQueryData(array $filters = [], ?int $perPage = null): array;
|
||||
|
||||
/**
|
||||
* Get statistics for the dashboard.
|
||||
@@ -131,4 +139,49 @@ interface InventoryServiceInterface
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardStats(): array;
|
||||
|
||||
/**
|
||||
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
|
||||
*
|
||||
* @param string $warehouseName
|
||||
* @return object
|
||||
*/
|
||||
public function findOrCreateWarehouseByName(string $warehouseName);
|
||||
|
||||
/**
|
||||
* Get top inventory value for dashboard.
|
||||
*/
|
||||
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Get items expiring soon for dashboard.
|
||||
*/
|
||||
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Get inventory summary (group by product) for a specific warehouse code
|
||||
*
|
||||
* @param string $code
|
||||
* @return \Illuminate\Support\Collection|null
|
||||
*/
|
||||
public function getPosInventoryByWarehouseCode(string $code);
|
||||
|
||||
/**
|
||||
* 處理批量入庫邏輯 (含批號產生與現有批號累加)。
|
||||
*
|
||||
* @param \App\Modules\Inventory\Models\Warehouse $warehouse
|
||||
* @param array $items 入庫品項清單
|
||||
* @param array $meta 資料包含 inboundDate, reason, notes
|
||||
* @return void
|
||||
*/
|
||||
public function processIncomingInventory(\App\Modules\Inventory\Models\Warehouse $warehouse, array $items, array $meta): void;
|
||||
|
||||
/**
|
||||
* 處理單一庫存項目的調整。
|
||||
*
|
||||
* @param \App\Modules\Inventory\Models\Inventory $inventory
|
||||
* @param array $data 包含 quantity, operation, type, reason, unit_cost 等
|
||||
* @return void
|
||||
*/
|
||||
public function adjustInventory(\App\Modules\Inventory\Models\Inventory $inventory, array $data): void;
|
||||
}
|
||||
81
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
81
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?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);
|
||||
|
||||
/**
|
||||
* 建立新商品。
|
||||
*
|
||||
* @param array $data
|
||||
* @return \App\Modules\Inventory\Models\Product
|
||||
*/
|
||||
public function createProduct(array $data);
|
||||
|
||||
/**
|
||||
* 更新現有商品。
|
||||
*
|
||||
* @param \App\Modules\Inventory\Models\Product $product
|
||||
* @param array $data
|
||||
* @return \App\Modules\Inventory\Models\Product
|
||||
*/
|
||||
public function updateProduct(\App\Modules\Inventory\Models\Product $product, array $data);
|
||||
|
||||
/**
|
||||
* 生成隨機 8 碼代號 (大寫英文+數字)。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function generateRandomCode();
|
||||
|
||||
/**
|
||||
* 生成隨機 13 碼條碼 (純數字)。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function generateRandomBarcode();
|
||||
|
||||
/**
|
||||
* 根據條碼或代號查找商品。
|
||||
*
|
||||
* @param string|null $barcode
|
||||
* @param string|null $code
|
||||
* @return \App\Modules\Inventory\Models\Product|null
|
||||
*/
|
||||
public function findByBarcodeOrCode(?string $barcode, ?string $code);
|
||||
}
|
||||
@@ -39,7 +39,11 @@ class AdjustDocController extends Controller
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$docs = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
@@ -59,7 +63,7 @@ class AdjustDocController extends Controller
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -35,9 +35,11 @@ class CountDocController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$countQuery = function ($query) {
|
||||
@@ -65,7 +67,7 @@ class CountDocController extends Controller
|
||||
|
||||
return Inertia::render('Inventory/Count/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -7,24 +7,28 @@ 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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Inventory\Services\DuplicateCheckService;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
protected $goodsReceiptService;
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
protected $duplicateCheckService;
|
||||
|
||||
public function __construct(
|
||||
GoodsReceiptService $goodsReceiptService,
|
||||
InventoryService $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
ProcurementServiceInterface $procurementService,
|
||||
DuplicateCheckService $duplicateCheckService
|
||||
) {
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
$this->duplicateCheckService = $duplicateCheckService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
@@ -59,7 +63,11 @@ class GoodsReceiptController extends Controller
|
||||
}
|
||||
|
||||
// 每頁筆數
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$receipts = $query->orderBy('created_at', 'desc')
|
||||
->paginate($perPage)
|
||||
@@ -84,7 +92,7 @@ class GoodsReceiptController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
public function show(Request $request, $id)
|
||||
{
|
||||
$receipt = GoodsReceipt::with([
|
||||
'warehouse',
|
||||
@@ -101,7 +109,12 @@ class GoodsReceiptController extends Controller
|
||||
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Show', [
|
||||
'receipt' => $receipt
|
||||
'receipt' => $receipt,
|
||||
'navigation' => [
|
||||
'from' => $request->query('from'),
|
||||
'from_id' => $request->query('from_id'),
|
||||
'from_label' => $request->query('from_label'),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -120,6 +133,7 @@ class GoodsReceiptController extends Controller
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'supplierId' => $po->vendor_id, // Alias for frontend
|
||||
'vendor_id' => $po->vendor_id,
|
||||
'vendor_name' => $po->vendor?->name ?? '',
|
||||
'warehouse_id' => $po->warehouse_id,
|
||||
@@ -127,15 +141,35 @@ class GoodsReceiptController extends Controller
|
||||
'items' => $po->items->map(function ($item) use ($products) {
|
||||
$product = $products->get($item->product_id);
|
||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
||||
|
||||
// 獲取單位名稱
|
||||
$baseUnitName = $product?->baseUnit?->name ?? '個';
|
||||
$largeUnitName = $product?->largeUnit?->name ?? '';
|
||||
|
||||
// 判斷當前採購使用的單位 (這需要從 PurchaseOrderItem 獲取 unit_id 並與產品的 large_unit_id 比較)
|
||||
$selectedUnit = 'base';
|
||||
if ($item->unit_id && $product && $item->unit_id == $product->large_unit_id) {
|
||||
$selectedUnit = 'large';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'productId' => $item->product_id, // Alias for frontend
|
||||
'product_id' => $item->product_id,
|
||||
'productName' => $product?->name ?? '', // Alias for frontend
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $product?->baseUnit?->name ?? '個',
|
||||
'unit' => $product?->baseUnit?->name ?? '個', // 預設顯示文字
|
||||
'selectedUnit' => $selectedUnit,
|
||||
'base_unit_id' => $product?->base_unit_id,
|
||||
'base_unit_name' => $baseUnitName,
|
||||
'large_unit_id' => $product?->large_unit_id,
|
||||
'large_unit_name' => $largeUnitName,
|
||||
'conversion_rate' => $product?->conversion_rate ?? 1,
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unitPrice' => $item->unit_price, // Alias for frontend
|
||||
'unit_price' => $item->unit_price,
|
||||
];
|
||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||
@@ -154,14 +188,193 @@ class GoodsReceiptController extends Controller
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
$validated = $request->validate($this->getItemValidationRules());
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->store($request->all());
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 編輯進貨單(僅草稿/退回狀態)
|
||||
*/
|
||||
public function edit(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, 'rejected'])) {
|
||||
return redirect()->route('goods-receipts.show', $goodsReceipt->id)
|
||||
->with('error', '只有草稿或被退回的進貨單可以編輯。');
|
||||
}
|
||||
|
||||
// 載入品項與產品資訊
|
||||
$goodsReceipt->load('items');
|
||||
|
||||
// 取得品項關聯的商品資訊
|
||||
$productIds = $goodsReceipt->items->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// 如果是標準採購,取得對應採購單的品項,以帶出預定數量與已收數量
|
||||
$poItems = collect();
|
||||
$po = null;
|
||||
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id) {
|
||||
$po = clone $this->procurementService->getPurchaseOrdersByIds([$goodsReceipt->purchase_order_id], ['items', 'vendor'])->first();
|
||||
if ($po) {
|
||||
$poItems = $po->items->keyBy('id');
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化品項資料
|
||||
$formattedItems = $goodsReceipt->items->map(function ($item) use ($products, $poItems) {
|
||||
$product = $products->get($item->product_id);
|
||||
$poItem = $poItems->get($item->purchase_order_item_id);
|
||||
|
||||
// 判斷單位
|
||||
$selectedUnit = 'base';
|
||||
if ($poItem && $product && $poItem->unit_id && $poItem->unit_id == $product->large_unit_id) {
|
||||
$selectedUnit = 'large';
|
||||
}
|
||||
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'purchase_order_item_id' => $item->purchase_order_item_id,
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $poItem && $selectedUnit === 'large' ? ($product?->largeUnit?->name ?? '') : ($product?->baseUnit?->name ?? '個'),
|
||||
'selectedUnit' => $selectedUnit,
|
||||
'base_unit_id' => $product?->base_unit_id,
|
||||
'base_unit_name' => $product?->baseUnit?->name ?? '個',
|
||||
'large_unit_id' => $product?->large_unit_id,
|
||||
'large_unit_name' => $product?->largeUnit?->name ?? '',
|
||||
'conversion_rate' => $product?->conversion_rate ?? 1,
|
||||
'quantity_ordered' => $poItem ? $poItem->quantity : null,
|
||||
'quantity_received_so_far' => $poItem ? ($poItem->received_quantity ?? 0) : null,
|
||||
'quantity_received' => (float) $item->quantity_received,
|
||||
'unit_price' => (float) $item->unit_price,
|
||||
'subtotal' => (float) $item->total_amount,
|
||||
'batch_number' => $item->batch_number ?? '',
|
||||
'batchMode' => 'existing',
|
||||
'originCountry' => 'TW',
|
||||
'expiry_date' => $item->expiry_date ?? '',
|
||||
];
|
||||
})->values();
|
||||
|
||||
// 同 create() 一樣傳入所需的 props
|
||||
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
|
||||
$productIdsForPOs = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
|
||||
$productsForPOs = $this->inventoryService->getProductsByIds($productIdsForPOs)->keyBy('id');
|
||||
|
||||
$formattedPOs = $pendingPOs->map(function ($po) use ($productsForPOs) {
|
||||
return [
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'supplierId' => $po->vendor_id,
|
||||
'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 ($productsForPOs) {
|
||||
$product = $productsForPOs->get($item->product_id);
|
||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
||||
|
||||
$selectedUnit = 'base';
|
||||
if ($item->unit_id && $product && $item->unit_id == $product->large_unit_id) {
|
||||
$selectedUnit = 'large';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'productId' => $item->product_id,
|
||||
'product_id' => $item->product_id,
|
||||
'productName' => $product?->name ?? '',
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $item->unit_id && $product && $item->unit_id == $product->large_unit_id ? ($product?->largeUnit?->name ?? '') : ($product?->baseUnit?->name ?? '個'),
|
||||
'selectedUnit' => $selectedUnit,
|
||||
'base_unit_id' => $product?->base_unit_id,
|
||||
'base_unit_name' => $product?->baseUnit?->name ?? '個',
|
||||
'large_unit_id' => $product?->large_unit_id,
|
||||
'large_unit_name' => $product?->largeUnit?->name ?? '',
|
||||
'conversion_rate' => $product?->conversion_rate ?? 1,
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unitPrice' => $item->unit_price,
|
||||
'unit_price' => $item->unit_price,
|
||||
];
|
||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||
];
|
||||
})->filter(fn($po) => $po['items']->count() > 0)->values();
|
||||
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
// Manual Hydration for Vendor
|
||||
$vendor = null;
|
||||
if ($goodsReceipt->vendor_id) {
|
||||
$vendor = $this->procurementService->getVendorsByIds([$goodsReceipt->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 格式化 Purchase Order 給前端顯示
|
||||
$formattedPO = null;
|
||||
if ($po) {
|
||||
$formattedPO = [
|
||||
'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->toArray(), // simplified since we just need items.length for display
|
||||
];
|
||||
}
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Create', [
|
||||
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||
'pendingPurchaseOrders' => $formattedPOs,
|
||||
'vendors' => $vendors,
|
||||
'receipt' => [
|
||||
'id' => $goodsReceipt->id,
|
||||
'code' => $goodsReceipt->code,
|
||||
'type' => $goodsReceipt->type,
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'vendor_id' => $goodsReceipt->vendor_id,
|
||||
'vendor' => $vendor,
|
||||
'purchase_order_id' => $goodsReceipt->purchase_order_id,
|
||||
'purchase_order' => $formattedPO,
|
||||
'received_date' => \Carbon\Carbon::parse($goodsReceipt->received_date)->format('Y-m-d'),
|
||||
'remarks' => $goodsReceipt->remarks,
|
||||
'items' => $formattedItems,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新進貨單
|
||||
*/
|
||||
public function update(Request $request, GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
$validated = $request->validate($this->getItemValidationRules());
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->update($goodsReceipt, $request->all());
|
||||
return redirect()->route('goods-receipts.show', $goodsReceipt->id)->with('success', '進貨單已更新');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得品項驗證規則
|
||||
*/
|
||||
private function getItemValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'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',
|
||||
@@ -169,14 +382,35 @@ class GoodsReceiptController extends Controller
|
||||
'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.*.unit_price' => 'nullable|numeric|min:0',
|
||||
'items.*.subtotal' => 'nullable|numeric|min:0',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.expiry_date' => 'nullable|date',
|
||||
]);
|
||||
'force' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
$this->goodsReceiptService->store($validated);
|
||||
/**
|
||||
* 預檢重複進貨 API
|
||||
*/
|
||||
public function checkDuplicate(Request $request)
|
||||
{
|
||||
$result = $this->duplicateCheckService->checkDuplicateReceipt($request->all());
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨單已建立');
|
||||
public function submit(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
if (!auth()->user()->can('goods_receipts.edit')) {
|
||||
return back()->with('error', '您沒有權限確認點收');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->submit($goodsReceipt);
|
||||
return back()->with('success', '進貨單已點收完成,庫存已增加並拋轉應付帳款');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// API to search POs
|
||||
@@ -193,6 +427,7 @@ class GoodsReceiptController extends Controller
|
||||
}
|
||||
|
||||
// API to search Products for Manual Entry
|
||||
// 支援 query='*' 回傳所有商品(用於 SearchableSelect 下拉選單)
|
||||
public function searchProducts(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
@@ -200,7 +435,12 @@ class GoodsReceiptController extends Controller
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$products = $this->inventoryService->getProductsByName($search);
|
||||
// 萬用字元:回傳所有商品
|
||||
if ($search === '*') {
|
||||
$products = $this->inventoryService->getProductsByName('');
|
||||
} else {
|
||||
$products = $this->inventoryService->getProductsByName($search);
|
||||
}
|
||||
|
||||
// Format for frontend
|
||||
$mapped = $products->map(function($product) {
|
||||
@@ -208,8 +448,8 @@ class GoodsReceiptController extends Controller
|
||||
'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
|
||||
'unit' => $product->baseUnit?->name ?? '個',
|
||||
'price' => $product->purchase_price ?? 0,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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'
|
||||
]);
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$analysisData = $this->turnoverService->getAnalysisData($filters, $perPage);
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,14 @@ use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
protected $coreService;
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(CoreServiceInterface $coreService)
|
||||
{
|
||||
public function __construct(
|
||||
CoreServiceInterface $coreService,
|
||||
\App\Modules\Inventory\Contracts\InventoryServiceInterface $inventoryService
|
||||
) {
|
||||
$this->coreService = $coreService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
public function index(Request $request, Warehouse $warehouse)
|
||||
@@ -37,7 +41,7 @@ class InventoryController extends Controller
|
||||
'inventories.lastIncomingTransaction',
|
||||
'inventories.lastOutgoingTransaction'
|
||||
]);
|
||||
$allProducts = Product::with('category')->get();
|
||||
$allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
|
||||
|
||||
// 1. 準備 availableProducts
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
@@ -53,9 +57,31 @@ class InventoryController extends Controller
|
||||
->pluck('safety_stock', 'product_id')
|
||||
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
||||
|
||||
$items = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction'])
|
||||
->get();
|
||||
$query = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction']);
|
||||
|
||||
// 加入搜尋過濾
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('batch_number', 'like', "%{$search}%")
|
||||
->orWhere(\Illuminate\Support\Facades\DB::raw("CONCAT('BATCH-', inventories.id)"), 'like', "%{$search}%")
|
||||
->orWhereHas('product', function ($pq) use ($search) {
|
||||
$pq->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 加入類型過濾
|
||||
if ($request->filled('type') && $request->input('type') !== 'all') {
|
||||
$type = $request->input('type');
|
||||
$query->whereHas('product.category', function ($cq) use ($type) {
|
||||
$cq->where('name', $type);
|
||||
});
|
||||
}
|
||||
|
||||
$items = $query->get();
|
||||
|
||||
// 判斷是否為販賣機並調整分組
|
||||
$isVending = $warehouse->type === 'vending';
|
||||
@@ -141,8 +167,8 @@ class InventoryController extends Controller
|
||||
public function create(Warehouse $warehouse)
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||
->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||
$products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
return [
|
||||
@@ -182,97 +208,20 @@ class InventoryController extends Controller
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['items'] as $item) {
|
||||
// ... (略,傳遞 unit_cost 交給 Service 處理) ...
|
||||
// 這裡需要修改呼叫 Service 的地方或直接更新邏輯
|
||||
// 為求快速,我將在此更新邏輯
|
||||
|
||||
$inventory = null;
|
||||
|
||||
if ($item['batchMode'] === 'existing') {
|
||||
// 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加)
|
||||
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
|
||||
// 更新成本 (若有傳入)
|
||||
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';
|
||||
$product = Product::find($item['productId']);
|
||||
|
||||
$batchNumber = Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
$originCountry,
|
||||
$validated['inboundDate']
|
||||
);
|
||||
|
||||
// 檢查是否存在
|
||||
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
|
||||
[
|
||||
'product_id' => $item['productId'],
|
||||
'batch_number' => $batchNumber
|
||||
],
|
||||
[
|
||||
'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,
|
||||
]
|
||||
);
|
||||
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
}
|
||||
|
||||
$currentQty = $inventory->quantity;
|
||||
$newQty = $currentQty + $item['quantity'];
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
// 更新總價值
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 寫入異動紀錄
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動入庫',
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $inventory->unit_cost, // 記錄成本
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
|
||||
'actual_time' => $validated['inboundDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
|
||||
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
|
||||
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
|
||||
$dt->setTimeFrom(now());
|
||||
} else {
|
||||
$dt->setSecond(now()->second);
|
||||
}
|
||||
$inboundDateTime = $dt->toDateTimeString();
|
||||
|
||||
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
|
||||
'inboundDate' => $inboundDateTime,
|
||||
'reason' => $validated['reason'],
|
||||
'notes' => $validated['notes'] ?? '',
|
||||
]);
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||
->with('success', '庫存記錄已儲存成功');
|
||||
@@ -401,81 +350,7 @@ class InventoryController extends Controller
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated, $inventory) {
|
||||
$currentQty = (float) $inventory->quantity;
|
||||
$newQty = (float) $validated['quantity'];
|
||||
|
||||
// 判斷是否來自調整彈窗 (包含 operation 參數)
|
||||
$isAdjustment = isset($validated['operation']);
|
||||
$changeQty = 0;
|
||||
|
||||
if ($isAdjustment) {
|
||||
switch ($validated['operation']) {
|
||||
case 'add':
|
||||
$changeQty = (float) $validated['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'subtract':
|
||||
$changeQty = -(float) $validated['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'set':
|
||||
$changeQty = $newQty - $currentQty;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 來自編輯頁面,直接 Set
|
||||
$changeQty = $newQty - $currentQty;
|
||||
}
|
||||
|
||||
// 更新成本 (若有傳)
|
||||
if (isset($validated['unit_cost'])) {
|
||||
$inventory->unit_cost = $validated['unit_cost'];
|
||||
}
|
||||
|
||||
// 更新庫存
|
||||
$inventory->quantity = $newQty;
|
||||
// 更新總值
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 異動類型映射
|
||||
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
|
||||
$typeMapping = [
|
||||
'manual_adjustment' => '手動調整庫存',
|
||||
'adjustment' => '盤點調整',
|
||||
'purchase_in' => '採購進貨',
|
||||
'sales_out' => '銷售出庫',
|
||||
'return_in' => '退貨入庫',
|
||||
'return_out' => '退貨出庫',
|
||||
'transfer_in' => '撥補入庫',
|
||||
'transfer_out' => '撥補出庫',
|
||||
];
|
||||
$chineseType = $typeMapping[$type] ?? $type;
|
||||
|
||||
// 如果是編輯頁面來的,且沒傳 type,設為手動編輯
|
||||
if (!$isAdjustment && !isset($validated['type'])) {
|
||||
$chineseType = '手動編輯';
|
||||
}
|
||||
|
||||
// 整理原因
|
||||
$reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
|
||||
if (isset($validated['notes'])) {
|
||||
$reason .= ' - ' . $validated['notes'];
|
||||
}
|
||||
|
||||
// 寫入異動紀錄
|
||||
if (abs($changeQty) > 0.0001) {
|
||||
$inventory->transactions()->create([
|
||||
'type' => $chineseType,
|
||||
'quantity' => $changeQty,
|
||||
'unit_cost' => $inventory->unit_cost, // 記錄
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $reason,
|
||||
'actual_time' => now(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
$this->inventoryService->adjustInventory($inventory, $validated);
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
|
||||
->with('success', '庫存資料已更新');
|
||||
|
||||
@@ -35,7 +35,12 @@ class InventoryReportController extends Controller
|
||||
$filters['date_to'] = date('Y-m-d');
|
||||
}
|
||||
|
||||
$reportData = $this->reportService->getReportData($filters, $request->input('per_page', 10));
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$reportData = $this->reportService->getReportData($filters, $perPage);
|
||||
$summary = $this->reportService->getSummary($filters);
|
||||
|
||||
return Inertia::render('Inventory/Report/Index', [
|
||||
|
||||
@@ -16,6 +16,12 @@ use App\Modules\Inventory\Imports\ProductImport;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
protected $productService;
|
||||
|
||||
public function __construct(\App\Modules\Inventory\Contracts\ProductServiceInterface $productService)
|
||||
{
|
||||
$this->productService = $productService;
|
||||
}
|
||||
/**
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
@@ -37,9 +43,11 @@ class ProductController extends Controller
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
@@ -104,12 +112,12 @@ class ProductController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
$categories = Category::where('is_active', true)->get();
|
||||
$categories = Category::select('id', 'name')->where('is_active', true)->get();
|
||||
|
||||
return Inertia::render('Product/Index', [
|
||||
'products' => $products,
|
||||
'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]),
|
||||
'categories' => $categories->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
@@ -164,8 +172,8 @@ class ProductController extends Controller
|
||||
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]),
|
||||
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -193,15 +201,7 @@ class ProductController extends Controller
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
if (empty($validated['barcode'])) {
|
||||
$validated['barcode'] = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
$product = Product::create($validated);
|
||||
$product = $this->productService->createProduct($validated);
|
||||
|
||||
return redirect()->route('products.index')->with('success', '商品已建立');
|
||||
}
|
||||
@@ -231,8 +231,8 @@ class ProductController extends Controller
|
||||
'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]),
|
||||
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -260,15 +260,7 @@ class ProductController extends Controller
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
if (empty($validated['barcode'])) {
|
||||
$validated['barcode'] = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
$product->update($validated);
|
||||
$this->productService->updateProduct($product, $validated);
|
||||
|
||||
if ($request->input('from') === 'show') {
|
||||
return redirect()->route('products.show', $product->id)->with('success', '商品已更新');
|
||||
@@ -292,7 +284,7 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function template()
|
||||
{
|
||||
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
|
||||
return Excel::download(new ProductTemplateExport, '商品匯入範本.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,39 +310,4 @@ class ProductController extends Controller
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class SafetyStockController extends Controller
|
||||
*/
|
||||
public function index(Warehouse $warehouse)
|
||||
{
|
||||
$allProducts = Product::with(['category', 'baseUnit'])->get();
|
||||
$allProducts = Product::select('id', 'name', 'category_id', 'base_unit_id')->with(['category:id,name', 'baseUnit:id,name'])->get();
|
||||
|
||||
// 準備可選商品列表
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
|
||||
@@ -24,7 +24,12 @@ class StockQueryController extends Controller
|
||||
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);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) ($filters['per_page'] ?? $defaultPerPage);
|
||||
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$result = $this->inventoryService->getStockQueryData($filters, $perPage);
|
||||
|
||||
|
||||
448
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
448
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
@@ -0,0 +1,448 @@
|
||||
<?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');
|
||||
}
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$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',
|
||||
]);
|
||||
|
||||
$submitImmediately = $request->boolean('submit_immediately');
|
||||
|
||||
$requisition = $this->service->create(
|
||||
$request->only(['store_warehouse_id', 'remark']),
|
||||
$request->items,
|
||||
auth()->id(),
|
||||
$submitImmediately
|
||||
);
|
||||
|
||||
if ($submitImmediately) {
|
||||
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',
|
||||
'transferOrder.items' // 載入產生的調撥單明細與批號
|
||||
])->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');
|
||||
|
||||
// 取得供貨倉庫的可用庫存
|
||||
$supplyInventories = collect();
|
||||
$supplyBatchesMap = collect();
|
||||
if ($requisition->supply_warehouse_id) {
|
||||
$supplyInventories = Inventory::where('warehouse_id', $requisition->supply_warehouse_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->select('product_id')
|
||||
->selectRaw('SUM(quantity) as total_qty')
|
||||
->selectRaw('SUM(reserved_quantity) as total_reserved')
|
||||
->groupBy('product_id')
|
||||
->get()
|
||||
->keyBy('product_id');
|
||||
|
||||
// 取得各商品的批號庫存
|
||||
$batches = Inventory::where('warehouse_id', $requisition->supply_warehouse_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->whereRaw('(quantity - reserved_quantity) > 0') // 僅撈出還有可用庫存的批號
|
||||
->select('id', 'product_id', 'batch_number', 'expiry_date', 'location as position')
|
||||
->selectRaw('quantity - reserved_quantity as available_qty')
|
||||
->get();
|
||||
|
||||
$supplyBatchesMap = $batches->groupBy('product_id');
|
||||
}
|
||||
|
||||
// 把調撥單明細 (核准的批號與數量) 整理成 map, key 為 product_id
|
||||
$approvedBatchesMap = collect();
|
||||
if ($requisition->transferOrder) {
|
||||
$approvedBatchesMap = $requisition->transferOrder->items->groupBy('product_id');
|
||||
}
|
||||
|
||||
$requisition->items->transform(function ($item) use ($inventories, $supplyInventories, $supplyBatchesMap, $approvedBatchesMap) {
|
||||
$item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0;
|
||||
|
||||
if ($supplyInventories->has($item->product_id)) {
|
||||
$stock = $supplyInventories->get($item->product_id);
|
||||
$item->supply_stock = max(0, $stock->total_qty - $stock->total_reserved);
|
||||
|
||||
// 附加該商品的批號可用庫存
|
||||
$batches = $supplyBatchesMap->get($item->product_id) ?? collect();
|
||||
$item->supply_batches = $batches->map(function ($batch) {
|
||||
return [
|
||||
'inventory_id' => $batch->id,
|
||||
'batch_number' => $batch->batch_number,
|
||||
'position' => $batch->position,
|
||||
'available_qty' => $batch->available_qty,
|
||||
'expiry_date' => $batch->expiry_date ? $batch->expiry_date->format('Y-m-d') : null,
|
||||
];
|
||||
})->values()->toArray();
|
||||
} else {
|
||||
$item->supply_stock = null;
|
||||
$item->supply_batches = [];
|
||||
}
|
||||
|
||||
// 附加已核准的批號資訊
|
||||
$approvedBatches = $approvedBatchesMap->get($item->product_id) ?? collect();
|
||||
$item->approved_batches = $approvedBatches->map(function ($transferItem) {
|
||||
// 如果是沒有批號管控的商品,batch_number 可能為 null
|
||||
return [
|
||||
'batch_number' => $transferItem->batch_number,
|
||||
'qty' => $transferItem->quantity,
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
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([
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:store_requisition_items,id',
|
||||
'items.*.approved_qty' => 'required|numeric|min:0',
|
||||
'items.*.batches' => 'nullable|array',
|
||||
'items.*.batches.*.inventory_id' => 'nullable|integer',
|
||||
'items.*.batches.*.batch_number' => 'nullable|string',
|
||||
'items.*.batches.*.qty' => 'required_with:items.*.batches|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
if (empty($requisition->supply_warehouse_id)) {
|
||||
return back()->withErrors(['supply_warehouse_id' => '請先選擇供貨倉庫']);
|
||||
}
|
||||
|
||||
$this->service->approve($requisition, $request->only(['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 updateSupplyWarehouse(Request $request, $id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
if ($requisition->status !== 'pending') {
|
||||
return back()->withErrors(['error' => '僅能在待審核狀態修改供貨倉庫']);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'supply_warehouse_id' => 'required|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$requisition->update([
|
||||
'supply_warehouse_id' => $request->supply_warehouse_id,
|
||||
]);
|
||||
|
||||
return redirect()->back()->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', '叫貨單已刪除');
|
||||
}
|
||||
}
|
||||
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Services\TraceabilityService;
|
||||
|
||||
class TraceabilityController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TraceabilityService $traceabilityService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 顯示批號溯源查詢的主頁面
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$batchNumber = $request->input('batch_number');
|
||||
$direction = $request->input('direction', 'backward'); // backward 或 forward
|
||||
|
||||
$result = null;
|
||||
|
||||
if ($batchNumber) {
|
||||
if ($direction === 'backward') {
|
||||
$result = $this->traceabilityService->traceBackward($batchNumber);
|
||||
} else {
|
||||
$result = $this->traceabilityService->traceForward($batchNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Inventory/Traceability/Index', [
|
||||
'search' => [
|
||||
'batch_number' => $batchNumber,
|
||||
'direction' => $direction,
|
||||
],
|
||||
'result' => $result
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
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\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TransferOrderController extends Controller
|
||||
@@ -24,6 +26,14 @@ class TransferOrderController extends Controller
|
||||
$query = InventoryTransferOrder::query()
|
||||
->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
|
||||
// 搜尋:單號或備註
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('doc_no', 'like', "%{$request->search}%")
|
||||
->orWhere('remarks', 'like', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
@@ -32,7 +42,11 @@ class TransferOrderController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$orders = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
@@ -51,8 +65,8 @@ class TransferOrderController extends Controller
|
||||
|
||||
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']),
|
||||
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['search', 'warehouse_id', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -65,6 +79,7 @@ class TransferOrderController extends Controller
|
||||
$validated = $request->validate([
|
||||
'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',
|
||||
'instant_post' => 'boolean',
|
||||
@@ -75,20 +90,40 @@ class TransferOrderController extends Controller
|
||||
]);
|
||||
|
||||
$remarks = $validated['remarks'] ?? $validated['notes'] ?? null;
|
||||
$transitWarehouseId = $validated['transit_warehouse_id'] ?? null;
|
||||
|
||||
$order = $this->transferService->createOrder(
|
||||
$fromId,
|
||||
$toId,
|
||||
$remarks,
|
||||
auth()->id()
|
||||
auth()->id(),
|
||||
$transitWarehouseId
|
||||
);
|
||||
|
||||
// 手動發送「已建立」日誌,因為服務層使用了 saveQuietly 抑制自動日誌
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->id())
|
||||
->event('created')
|
||||
->withProperties([
|
||||
'attributes' => [
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_id' => $order->from_warehouse_id,
|
||||
'to_warehouse_id' => $order->to_warehouse_id,
|
||||
'transit_warehouse_id' => $order->transit_warehouse_id,
|
||||
'remarks' => $order->remarks,
|
||||
'status' => $order->status,
|
||||
'created_by' => $order->created_by,
|
||||
]
|
||||
])
|
||||
->log('created');
|
||||
|
||||
if ($request->input('instant_post') === true) {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
$this->transferService->dispatch($order, auth()->id());
|
||||
|
||||
return redirect()->back()->with('success', '撥補成功,庫存已更新');
|
||||
} catch (\Exception $e) {
|
||||
// 如果過帳失敗,雖然單據已建立,但應回報錯誤
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
@@ -99,22 +134,37 @@ class TransferOrderController extends Controller
|
||||
|
||||
public function show(InventoryTransferOrder $order)
|
||||
{
|
||||
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'transitWarehouse', 'createdBy', 'postedBy', 'dispatchedBy', 'receivedBy', 'storeRequisition']);
|
||||
|
||||
$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, // 用於判斷是否為販賣機
|
||||
'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)
|
||||
@@ -136,20 +186,56 @@ class TransferOrderController extends Controller
|
||||
}),
|
||||
];
|
||||
|
||||
// 取得在途倉庫列表供前端選擇
|
||||
$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,
|
||||
]);
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Show', [
|
||||
'order' => $orderData,
|
||||
'transitWarehouses' => $transitWarehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryTransferOrder $order)
|
||||
{
|
||||
// 收貨動作:僅限 dispatched 狀態
|
||||
if ($request->input('action') === 'receive') {
|
||||
if ($order->status !== 'dispatched') {
|
||||
return redirect()->back()->with('error', '僅能對已出貨的調撥單進行收貨確認');
|
||||
}
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
// 以下操作僅限草稿
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
// 1. 先更新資料 (如果請求中包含 items,則先執行儲存)
|
||||
// 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')) {
|
||||
if ($order->storeRequisition()->exists()) {
|
||||
return redirect()->back()->with('error', '由叫貨單自動產生的調撥單無法修改明細');
|
||||
}
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
@@ -167,20 +253,21 @@ class TransferOrderController extends Controller
|
||||
$order->remarks = $request->input('remarks');
|
||||
}
|
||||
|
||||
if ($itemsChanged || $remarksChanged) {
|
||||
// [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
|
||||
if ($itemsChanged || $remarksChanged || $order->isDirty()) {
|
||||
$order->touch();
|
||||
$message = '儲存成功';
|
||||
} else {
|
||||
$message = '資料未變更';
|
||||
}
|
||||
|
||||
// 2. 判斷是否需要過帳
|
||||
// 3. 判斷是否需要出貨/過帳
|
||||
if ($request->input('action') === 'post') {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
$this->transferService->dispatch($order, auth()->id());
|
||||
$hasTransit = !empty($order->transit_warehouse_id);
|
||||
$successMsg = $hasTransit ? '調撥單已出貨,庫存已轉入在途倉' : '調撥單已過帳完成';
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已過帳完成');
|
||||
->with('success', $successMsg);
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->back()->withErrors($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
@@ -197,6 +284,17 @@ class TransferOrderController extends Controller
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
|
||||
// 刪除前必須先釋放預留庫存
|
||||
foreach ($order->items as $item) {
|
||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
if ($inv) {
|
||||
$inv->releaseReservedQuantity($item->quantity);
|
||||
}
|
||||
}
|
||||
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ class WarehouseController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
|
||||
@@ -113,9 +115,22 @@ class WarehouseController extends Controller
|
||||
'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,
|
||||
'transitWarehouses' => $transitWarehouses,
|
||||
'filters' => $request->only(['search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
@@ -130,6 +145,7 @@ class WarehouseController extends Controller
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
Warehouse::create($validated);
|
||||
@@ -147,6 +163,7 @@ class WarehouseController extends Controller
|
||||
'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);
|
||||
|
||||
22
app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php
Normal file
22
app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GoodsReceiptApprovedEvent
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public $goodsReceiptId;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(int $goodsReceiptId)
|
||||
{
|
||||
$this->goodsReceiptId = $goodsReceiptId;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,9 @@ class StockQueryExport implements FromCollection, WithHeadings, WithMapping, Sho
|
||||
$search = $this->filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.code', 'like', "%{$search}%")
|
||||
->orWhere('products.name', 'like', "%{$search}%");
|
||||
->orWhere('products.name', 'like', "%{$search}%")
|
||||
->orWhere('inventories.batch_number', 'like', "%{$search}%")
|
||||
->orWhere(\Illuminate\Support\Facades\DB::raw("CONCAT('BATCH-', inventories.id)"), 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
if (!empty($this->filters['status'])) {
|
||||
|
||||
@@ -23,7 +23,11 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
|
||||
{
|
||||
HeadingRowFormatter::default('none');
|
||||
$this->warehouse = $warehouse;
|
||||
$this->inboundDate = $inboundDate;
|
||||
|
||||
// 修正時間精度:將選定的日期與「現在的時分秒」結合
|
||||
// 這樣既能保留使用者選的日期,又能提供精確的紀錄時點排順序
|
||||
$this->inboundDate = \Illuminate\Support\Carbon::parse($inboundDate)->setTimeFrom(now())->toDateTimeString();
|
||||
|
||||
$this->notes = $notes;
|
||||
}
|
||||
|
||||
@@ -95,7 +99,7 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
|
||||
// 更新單價與總價值
|
||||
$inventory->unit_cost = $unitCost;
|
||||
$inventory->total_value = $inventory->quantity * $unitCost;
|
||||
$inventory->save();
|
||||
$inventory->saveQuietly();
|
||||
|
||||
// 記錄交易歷史
|
||||
$inventory->transactions()->create([
|
||||
|
||||
@@ -9,47 +9,88 @@ use Illuminate\Validation\Rule;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
|
||||
|
||||
class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapping
|
||||
/**
|
||||
* 商品匯入主類別
|
||||
*
|
||||
* 實作 WithMultipleSheets 以限定只讀取第一個工作表(資料頁),
|
||||
* 跳過第二個工作表(填寫說明頁),避免說明頁的資料被誤匯入並觸發驗證錯誤。
|
||||
*/
|
||||
class ProductImport implements WithMultipleSheets
|
||||
{
|
||||
private $categories;
|
||||
private $units;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 禁用標題格式化,保留中文標題
|
||||
HeadingRowFormatter::default('none');
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定只處理第一個工作表 (index 0)
|
||||
*/
|
||||
public function sheets(): array
|
||||
{
|
||||
return [
|
||||
0 => new ProductDataSheetImport(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品匯入 - 資料工作表處理類別
|
||||
*
|
||||
* 負責實際的資料解析、驗證與儲存邏輯。
|
||||
* 只會被套用到 Excel 的第一個工作表(資料頁)。
|
||||
*/
|
||||
class ProductDataSheetImport implements ToModel, WithHeadingRow, WithValidation, WithMapping, SkipsEmptyRows
|
||||
{
|
||||
private $categories;
|
||||
private $units;
|
||||
private $productService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 快取所有類別與單位,避免 N+1 查詢
|
||||
$this->categories = Category::pluck('id', 'name');
|
||||
$this->units = Unit::pluck('id', 'name');
|
||||
$this->productService = app(\App\Modules\Inventory\Contracts\ProductServiceInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $row
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
* 資料映射:將 Excel 原始標題(含「(選填)」)對應到乾淨的鍵名
|
||||
*
|
||||
* 注意:WithValidation 驗證的是 map() 之前的原始資料,
|
||||
* 因此 rules() 中的鍵名必須匹配 Excel 的原始標題。
|
||||
* map() 的返回值只影響 model() 接收到的資料。
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
|
||||
if (isset($row['商品代號'])) {
|
||||
$row['商品代號'] = (string) $row['商品代號'];
|
||||
}
|
||||
if (isset($row['條碼'])) {
|
||||
$row['條碼'] = (string) $row['條碼'];
|
||||
}
|
||||
|
||||
return $row;
|
||||
$code = $row['商品代號(選填)'] ?? $row['商品代號'] ?? null;
|
||||
$barcode = $row['條碼(選填)'] ?? $row['條碼'] ?? null;
|
||||
|
||||
return [
|
||||
'商品代號' => $code !== null ? (string)$code : null,
|
||||
'條碼' => $barcode !== null ? (string)$barcode : null,
|
||||
'商品名稱' => $row['商品名稱'] ?? null,
|
||||
'類別名稱' => $row['類別名稱'] ?? null,
|
||||
'品牌' => $row['品牌'] ?? null,
|
||||
'規格' => $row['規格'] ?? null,
|
||||
'基本單位' => $row['基本單位'] ?? null,
|
||||
'大單位' => $row['大單位'] ?? null,
|
||||
'換算率' => isset($row['換算率']) ? (float)$row['換算率'] : null,
|
||||
'成本價' => isset($row['成本價']) ? (float)$row['成本價'] : null,
|
||||
'售價' => isset($row['售價']) ? (float)$row['售價'] : null,
|
||||
'會員價' => isset($row['會員價']) ? (float)$row['會員價'] : null,
|
||||
'批發價' => isset($row['批發價']) ? (float)$row['批發價'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
*/
|
||||
* @param array $row (map() 回傳的乾淨鍵名陣列)
|
||||
*/
|
||||
public function model(array $row)
|
||||
{
|
||||
// 查找關聯 ID
|
||||
@@ -65,15 +106,8 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
||||
$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();
|
||||
}
|
||||
// Upsert 邏輯:透過 Service 統一查找與處理
|
||||
$product = $this->productService->findByBarcodeOrCode($barcode, $code);
|
||||
|
||||
$data = [
|
||||
'name' => $row['商品名稱'],
|
||||
@@ -91,65 +125,27 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
||||
];
|
||||
|
||||
if ($product) {
|
||||
// 更新現有商品
|
||||
$product->update($data);
|
||||
return null; // 返回 null 以避免 Maatwebsite/Excel 嘗試再次 insert
|
||||
$this->productService->updateProduct($product, $data);
|
||||
} else {
|
||||
if (!empty($code)) $data['code'] = $code;
|
||||
if (!empty($barcode)) $data['barcode'] = $barcode;
|
||||
$this->productService->createProduct($data);
|
||||
}
|
||||
|
||||
// 建立新商品:處理代碼與條碼自動生成
|
||||
if (empty($code)) {
|
||||
$code = $this->generateRandomCode();
|
||||
}
|
||||
if (empty($barcode)) {
|
||||
$barcode = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
$data['code'] = $code;
|
||||
$data['barcode'] = $barcode;
|
||||
|
||||
return new Product($data);
|
||||
return null; // 返回 null,因為 Service 已經處理完儲存
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成隨機 8 碼代號 (大寫英文+數字)
|
||||
* 驗證規則
|
||||
*
|
||||
* 鍵名必須匹配 Excel 原始標題(含「(選填)」後綴),
|
||||
* 因為 WithValidation 驗證的是 map() 之前的原始資料。
|
||||
*/
|
||||
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'],
|
||||
'商品代號(選填)' => ['nullable', 'string', 'min:2', 'max:8'],
|
||||
'條碼(選填)' => ['nullable', 'string'],
|
||||
'商品名稱' => ['required', 'string'],
|
||||
'類別名稱' => ['required', function($attribute, $value, $fail) {
|
||||
if (!isset($this->categories[$value])) {
|
||||
@@ -174,4 +170,16 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
||||
'批發價' => ['nullable', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 自訂驗證錯誤訊息的欄位名稱
|
||||
* 把含 "(選填)" 後綴的欄位顯示為友善名稱
|
||||
*/
|
||||
public function customValidationAttributes(): array
|
||||
{
|
||||
return [
|
||||
'商品代號(選填)' => '商品代號',
|
||||
'條碼(選填)' => '條碼',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,20 @@ 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);
|
||||
$this->app->bind(
|
||||
\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class,
|
||||
\App\Modules\Inventory\Services\GoodsReceiptService::class
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
@@ -29,12 +29,27 @@ class Category extends Model
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['name'] = $this->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
foreach (['created_by', 'updated_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ class GoodsReceipt extends Model
|
||||
use HasFactory, SoftDeletes;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PENDING_AUDIT = 'pending_audit';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'type',
|
||||
@@ -35,6 +40,47 @@ class GoodsReceipt extends Model
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['doc_no'] = $this->code;
|
||||
$snapshot['warehouse_name'] = $this->warehouse?->name;
|
||||
|
||||
if (!isset($snapshot['vendor_name']) && $this->vendor_id) {
|
||||
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
|
||||
->getVendorsByIds([$this->vendor_id])->first();
|
||||
$snapshot['vendor_name'] = $vendor?->name;
|
||||
}
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = app(\App\Modules\Core\Contracts\CoreServiceInterface::class)->getUser($data[$f])?->name;
|
||||
}
|
||||
}
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
|
||||
}
|
||||
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
|
||||
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
|
||||
->getVendorsByIds([$data['vendor_id']])->first();
|
||||
$data['vendor_id'] = $vendor?->name;
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(GoodsReceiptItem::class);
|
||||
|
||||
@@ -17,6 +17,7 @@ class Inventory extends Model
|
||||
'warehouse_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'reserved_quantity',
|
||||
'location',
|
||||
'unit_cost',
|
||||
'total_value',
|
||||
@@ -34,6 +35,8 @@ class Inventory extends Model
|
||||
protected $casts = [
|
||||
'arrival_date' => 'date:Y-m-d',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
'quantity' => 'decimal:4',
|
||||
'reserved_quantity' => 'decimal:4',
|
||||
'unit_cost' => 'decimal:4',
|
||||
'total_value' => 'decimal:4',
|
||||
];
|
||||
@@ -55,8 +58,11 @@ class Inventory extends Model
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$attributes = $properties['attributes'] ?? [];
|
||||
// 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改
|
||||
@@ -66,11 +72,28 @@ class Inventory extends Model
|
||||
|
||||
// 如果已設定原因,則進行捕捉
|
||||
if ($this->activityLogReason) {
|
||||
$attributes['_reason'] = $this->activityLogReason;
|
||||
$properties['attributes']['_reason'] = $this->activityLogReason;
|
||||
}
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 全域 ID 轉名稱邏輯
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 倉庫 ID 轉換
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
|
||||
}
|
||||
// 商品 ID 轉換
|
||||
if (isset($data['product_id']) && is_numeric($data['product_id'])) {
|
||||
$data['product_id'] = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
@@ -109,7 +132,33 @@ class Inventory extends Model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 可用庫存(實體庫存 - 預留庫存)
|
||||
*/
|
||||
public function getAvailableQuantityAttribute()
|
||||
{
|
||||
return max(0, $this->quantity - $this->reserved_quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加預留庫存(鎖定)
|
||||
*/
|
||||
public function reserveQuantity(float|int $amount)
|
||||
{
|
||||
if ($amount <= 0) return;
|
||||
$this->reserved_quantity += $amount;
|
||||
$this->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* 釋放預留庫存(解鎖)
|
||||
*/
|
||||
public function releaseReservedQuantity(float|int $amount)
|
||||
{
|
||||
if ($amount <= 0) return;
|
||||
$this->reserved_quantity = max(0, $this->reserved_quantity - $amount);
|
||||
$this->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生批號
|
||||
|
||||
@@ -10,6 +10,7 @@ class InventoryTransaction extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
|
||||
use HasFactory;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
@@ -41,4 +42,49 @@ class InventoryTransaction extends Model
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->dontLogIfAttributesChangedOnly(['updated_at'])
|
||||
// 取消 logOnlyDirty,代表新增時(created)也要留紀錄
|
||||
->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'] ?? [];
|
||||
|
||||
// 試著取得商品與倉庫名稱來作為主要顯示依據
|
||||
$inventory = $this->inventory;
|
||||
if ($inventory) {
|
||||
$snapshot['warehouse_name'] = $inventory->warehouse ? $inventory->warehouse->name : null;
|
||||
$snapshot['product_name'] = $inventory->product ? $inventory->product->name : null;
|
||||
$snapshot['batch_number'] = $inventory->batch_number;
|
||||
}
|
||||
|
||||
// 把異動類型與數量也拉到 snapshot
|
||||
$snapshot['type'] = $this->type;
|
||||
$snapshot['quantity'] = $this->quantity;
|
||||
$snapshot['reason'] = $this->reason;
|
||||
|
||||
// 替換使用者名稱
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
if (isset($data['user_id']) && is_numeric($data['user_id'])) {
|
||||
$data['user_id'] = \App\Modules\Core\Models\User::find($data['user_id'])?->name;
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,21 +36,23 @@ class InventoryTransferOrder extends Model
|
||||
if ($eventName === 'created') {
|
||||
$activity->description = 'created';
|
||||
} elseif ($eventName === 'updated') {
|
||||
// 如果屬性中有 status 且變更為 completed,將描述改為 posted
|
||||
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
|
||||
$activity->description = 'posted';
|
||||
$eventName = 'posted'; // 供後續快照邏輯判定
|
||||
$eventName = 'posted';
|
||||
} else {
|
||||
$activity->description = 'updated';
|
||||
}
|
||||
}
|
||||
|
||||
// 處理倉庫 ID 轉名稱
|
||||
// 處理 ID 轉名稱 (核心:支援 attributes 與 old 的自動轉換)
|
||||
$idToNameFields = [
|
||||
'from_warehouse_id' => 'fromWarehouse',
|
||||
'to_warehouse_id' => 'toWarehouse',
|
||||
'transit_warehouse_id' => 'transitWarehouse',
|
||||
'created_by' => 'createdBy',
|
||||
'posted_by' => 'postedBy',
|
||||
'dispatched_by' => 'dispatchedBy',
|
||||
'received_by' => 'receivedBy',
|
||||
];
|
||||
|
||||
foreach (['attributes', 'old'] as $part) {
|
||||
@@ -58,14 +60,20 @@ class InventoryTransferOrder extends Model
|
||||
foreach ($idToNameFields as $idField => $relation) {
|
||||
if (isset($properties[$part][$idField])) {
|
||||
$id = $properties[$part][$idField];
|
||||
$nameField = str_replace('_id', '_name', $idField);
|
||||
if (!$id) continue;
|
||||
|
||||
$nameField = str_replace('_id', '_name', $idField);
|
||||
$name = null;
|
||||
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
|
||||
$name = $this->$relation->name;
|
||||
} else {
|
||||
$model = $this->$relation()->getRelated()->find($id);
|
||||
$name = $model ? $model->name : "ID: $id";
|
||||
try {
|
||||
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
|
||||
$name = $this->$relation->name;
|
||||
} else {
|
||||
$relatedModel = $this->$relation()->getRelated();
|
||||
$model = $relatedModel->find($id);
|
||||
$name = $model ? ($model->name ?? $model->display_name ?? "ID: $id") : "ID: $id";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$name = "ID: $id";
|
||||
}
|
||||
$properties[$part][$nameField] = $name;
|
||||
}
|
||||
@@ -73,7 +81,7 @@ class InventoryTransferOrder extends Model
|
||||
}
|
||||
}
|
||||
|
||||
// 基本單據資訊快照 (包含單號、來源、目的地)
|
||||
// 基本單據資訊快照
|
||||
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
|
||||
$properties['snapshot'] = [
|
||||
'doc_no' => $this->doc_no,
|
||||
@@ -85,8 +93,6 @@ class InventoryTransferOrder extends Model
|
||||
|
||||
// 移除輔助欄位與雜訊
|
||||
if (isset($properties['attributes'])) {
|
||||
unset($properties['attributes']['from_warehouse_name']);
|
||||
unset($properties['attributes']['to_warehouse_name']);
|
||||
unset($properties['attributes']['activityProperties']);
|
||||
unset($properties['attributes']['updated_at']);
|
||||
}
|
||||
@@ -94,7 +100,7 @@ class InventoryTransferOrder extends Model
|
||||
unset($properties['old']['updated_at']);
|
||||
}
|
||||
|
||||
// 合併暫存屬性 (例如 items_diff)
|
||||
// 合併暫存屬性 (重要:例如 items_diff)
|
||||
if (!empty($this->activityProperties)) {
|
||||
$properties = array_merge($properties, $this->activityProperties);
|
||||
}
|
||||
@@ -106,16 +112,23 @@ class InventoryTransferOrder extends Model
|
||||
'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()
|
||||
@@ -163,8 +176,28 @@ class InventoryTransferOrder extends Model
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,30 +85,50 @@ class Product extends Model
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$attributes = $properties['attributes'] ?? [];
|
||||
// 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 處理分類名稱快照
|
||||
if (isset($attributes['category_id'])) {
|
||||
$category = Category::find($attributes['category_id']);
|
||||
$snapshot['category_name'] = $category ? $category->name : null;
|
||||
}
|
||||
|
||||
// 處理單位名稱快照
|
||||
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
|
||||
foreach ($unitFields as $field) {
|
||||
if (isset($attributes[$field])) {
|
||||
$unit = Unit::find($attributes[$field]);
|
||||
$nameKey = str_replace('_id', '_name', $field);
|
||||
$snapshot[$nameKey] = $unit ? $unit->name : null;
|
||||
}
|
||||
}
|
||||
|
||||
// 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂")
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 全域 ID 轉名稱邏輯
|
||||
$resolver = function (&$data) use (&$snapshot) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 處理分類名稱
|
||||
if (isset($data['category_id']) && is_numeric($data['category_id'])) {
|
||||
$categoryName = Category::find($data['category_id'])?->name;
|
||||
$data['category_id'] = $categoryName;
|
||||
if (!isset($snapshot['category_name']) && $categoryName) {
|
||||
$snapshot['category_name'] = $categoryName;
|
||||
}
|
||||
}
|
||||
|
||||
// 處理單位名稱
|
||||
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
|
||||
foreach ($unitFields as $field) {
|
||||
if (isset($data[$field]) && is_numeric($data[$field])) {
|
||||
$unitName = Unit::find($data[$field])?->name;
|
||||
$data[$field] = $unitName;
|
||||
|
||||
$nameKey = str_replace('_id', '_name', $field);
|
||||
if (!isset($snapshot[$nameKey]) && $unitName) {
|
||||
$snapshot[$nameKey] = $unitName;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
// 因為 resolver 內部可能更新了 snapshot,所以再覆寫一次
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
220
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
220
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array 暫存的活動紀錄屬性 (不會存入資料庫)
|
||||
*/
|
||||
public $activityProperties = [];
|
||||
|
||||
/**
|
||||
* 自定義日誌屬性,解析 ID 為名稱
|
||||
*/
|
||||
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties->toArray();
|
||||
|
||||
// 處置日誌事件與狀態中文化
|
||||
$statusMap = [
|
||||
'draft' => '草稿',
|
||||
'pending' => '待審核',
|
||||
'approved' => '已核准',
|
||||
'rejected' => '已駁回',
|
||||
'completed' => '已完成',
|
||||
];
|
||||
|
||||
// 處理 ID 轉名稱
|
||||
$idToNameFields = [
|
||||
'store_warehouse_id' => 'storeWarehouse',
|
||||
'supply_warehouse_id' => 'supplyWarehouse',
|
||||
'created_by' => 'createdBy',
|
||||
'approved_by' => 'approvedBy',
|
||||
'transfer_order_id' => 'transferOrder',
|
||||
];
|
||||
|
||||
foreach (['attributes', 'old'] as $part) {
|
||||
if (isset($properties[$part])) {
|
||||
// 1. 解析狀態中文並替換原始 status 欄位
|
||||
if (isset($properties[$part]['status'])) {
|
||||
$statusValue = $properties[$part]['status'];
|
||||
$properties[$part]['status'] = $statusMap[$statusValue] ?? $statusValue;
|
||||
}
|
||||
|
||||
// 2. 解析關連名稱
|
||||
foreach ($idToNameFields as $idField => $relation) {
|
||||
if (isset($properties[$part][$idField])) {
|
||||
$id = $properties[$part][$idField];
|
||||
if (!$id) continue;
|
||||
|
||||
$nameField = str_replace('_id', '_name', $idField);
|
||||
if (str_contains($idField, '_by')) {
|
||||
$nameField = str_replace('_by', '_user_name', $idField);
|
||||
}
|
||||
|
||||
$name = null;
|
||||
try {
|
||||
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
|
||||
// 特別處理調撥單號
|
||||
$name = ($relation === 'transferOrder') ? $this->$relation->doc_no : $this->$relation->name;
|
||||
} else {
|
||||
$relatedModel = $this->$relation()->getRelated();
|
||||
$model = $relatedModel->find($id);
|
||||
if ($model) {
|
||||
$name = ($relation === 'transferOrder') ? ($model->doc_no ?? "ID: $id") : ($model->name ?? "ID: $id");
|
||||
} else {
|
||||
$name = "ID: $id";
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$name = "ID: $id";
|
||||
}
|
||||
$properties[$part][$nameField] = $name;
|
||||
// 移除原生的技術 ID 欄位,讓詳情更乾淨
|
||||
unset($properties[$part][$idField]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 基本單據資訊快照
|
||||
$properties['snapshot'] = [
|
||||
'doc_no' => $this->doc_no,
|
||||
'store_warehouse_name' => $this->storeWarehouse?->name,
|
||||
'supply_warehouse_name' => $this->supplyWarehouse?->name,
|
||||
'status' => $statusMap[$this->status] ?? $this->status,
|
||||
];
|
||||
|
||||
// 移除雜訊與重複欄位
|
||||
if (isset($properties['attributes'])) {
|
||||
unset($properties['attributes']['updated_at']);
|
||||
unset($properties['attributes']['activityProperties']);
|
||||
}
|
||||
if (isset($properties['old'])) {
|
||||
unset($properties['old']['updated_at']);
|
||||
}
|
||||
|
||||
// 合併暫存屬性 (例如 items_diff)
|
||||
if (!empty($this->activityProperties)) {
|
||||
$properties = array_merge($properties, $this->activityProperties);
|
||||
}
|
||||
|
||||
$activity->properties = collect($properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動產生單號 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);
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,27 @@ class Unit extends Model
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['name'] = $this->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
foreach (['created_by', 'updated_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class Warehouse extends Model
|
||||
'description',
|
||||
'license_plate',
|
||||
'driver_name',
|
||||
'default_transit_warehouse_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -36,12 +37,31 @@ class Warehouse extends Model
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['name'] = $this->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
foreach (['created_by', 'updated_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['default_transit_warehouse_id']) && is_numeric($data['default_transit_warehouse_id'])) {
|
||||
$data['default_transit_warehouse_id'] = self::find($data['default_transit_warehouse_id'])?->name;
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
@@ -50,7 +70,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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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 () {
|
||||
|
||||
@@ -32,6 +33,16 @@ Route::middleware('auth')->group(function () {
|
||||
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');
|
||||
});
|
||||
|
||||
// 批號溯源 (Lot Traceability)
|
||||
Route::middleware('permission:inventory_traceability.view')->group(function () {
|
||||
Route::get('/inventory/traceability', [\App\Modules\Inventory\Controllers\TraceabilityController::class, 'index'])->name('inventory.traceability.index');
|
||||
});
|
||||
|
||||
// 類別管理 (用於商品對話框) - 需要商品權限
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||
@@ -141,12 +152,54 @@ Route::middleware('auth')->group(function () {
|
||||
->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::patch('/store-requisitions/{id}/supply-warehouse', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'updateSupplyWarehouse'])->name('store-requisitions.update-supply-warehouse');
|
||||
});
|
||||
|
||||
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::get('/goods-receipts/{goods_receipt}/edit', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'edit'])->middleware('permission:goods_receipts.edit')->name('goods-receipts.edit');
|
||||
Route::put('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'update'])->middleware('permission:goods_receipts.edit')->name('goods-receipts.update');
|
||||
Route::post('/goods-receipts/check-duplicate', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'checkDuplicate'])
|
||||
->middleware('permission:goods_receipts.create')
|
||||
->name('goods-receipts.check-duplicate');
|
||||
Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
|
||||
|
||||
// 點收提交路由
|
||||
Route::post('/goods-receipts/{goods_receipt}/submit', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'submit'])
|
||||
->middleware('permission:goods_receipts.edit')
|
||||
->name('goods-receipts.submit');
|
||||
|
||||
Route::delete('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'destroy'])
|
||||
->middleware('permission:goods_receipts.delete')
|
||||
->name('goods-receipts.destroy');
|
||||
|
||||
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');
|
||||
|
||||
@@ -5,10 +5,17 @@ use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdjustService
|
||||
{
|
||||
protected InventoryServiceInterface $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
|
||||
{
|
||||
return InventoryAdjustDoc::create([
|
||||
@@ -37,16 +44,23 @@ class AdjustService
|
||||
);
|
||||
|
||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||
$itemsToInsert = [];
|
||||
foreach ($countDoc->items as $item) {
|
||||
if (abs($item->diff_qty) < 0.0001) continue;
|
||||
|
||||
$adjDoc->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'adjust_doc_id' => $adjDoc->id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'qty_before' => $item->system_qty,
|
||||
'adjust_qty' => $item->diff_qty,
|
||||
'notes' => "盤點差異: " . $item->diff_qty,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
if (!empty($itemsToInsert)) {
|
||||
InventoryAdjustItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
return $adjDoc;
|
||||
@@ -77,25 +91,35 @@ class AdjustService
|
||||
|
||||
$doc->items()->delete();
|
||||
|
||||
$itemsToInsert = [];
|
||||
$productIds = collect($itemsData)->pluck('product_id')->unique()->toArray();
|
||||
$products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||
|
||||
// 批次取得當前庫存
|
||||
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->get();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->where('product_id', $data['product_id'])
|
||||
$inventory = $inventories->where('product_id', $data['product_id'])
|
||||
->where('batch_number', $data['batch_number'] ?? null)
|
||||
->first();
|
||||
|
||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||
|
||||
$newItem = $doc->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'adjust_doc_id' => $doc->id,
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'qty_before' => $qtyBefore,
|
||||
'adjust_qty' => $data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// 更新日誌中的品項列表
|
||||
$productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
|
||||
$productName = $products->get($data['product_id'])?->name ?? '未知商品';
|
||||
$found = false;
|
||||
foreach ($updatedItems as $idx => $ui) {
|
||||
if ($ui['product_name'] === $productName && $ui['new'] === null) {
|
||||
@@ -119,6 +143,10 @@ class AdjustService
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($itemsToInsert)) {
|
||||
InventoryAdjustItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
// 清理沒被更新到的舊品項 (即真正被刪除的)
|
||||
$finalUpdatedItems = [];
|
||||
foreach ($updatedItems as $ui) {
|
||||
@@ -155,35 +183,35 @@ class AdjustService
|
||||
foreach ($doc->items as $item) {
|
||||
if ($item->adjust_qty == 0) continue;
|
||||
|
||||
$inventory = Inventory::firstOrNew([
|
||||
// 補上 lockForUpdate() 防止併發衝突
|
||||
$inventory = Inventory::where([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
]);
|
||||
])->lockForUpdate()->first();
|
||||
|
||||
// 如果是新建立的 object (id 為空),需要初始化 default
|
||||
if (!$inventory) {
|
||||
$inventory = new Inventory([
|
||||
'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;
|
||||
$inventory->total_value = 0;
|
||||
$inventory->saveQuietly();
|
||||
}
|
||||
|
||||
$oldQty = $inventory->quantity;
|
||||
$newQty = $oldQty + $item->adjust_qty;
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
$inventory->total_value = $newQty * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 建立 Transaction
|
||||
$inventory->transactions()->create([
|
||||
'type' => '庫存調整',
|
||||
$this->inventoryService->adjustInventory($inventory, [
|
||||
'operation' => 'add',
|
||||
'quantity' => $item->adjust_qty,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $oldQty,
|
||||
'balance_after' => $newQty,
|
||||
'type' => 'adjustment',
|
||||
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
'notes' => $item->notes,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
192
app/Modules/Inventory/Services/DuplicateCheckService.php
Normal file
192
app/Modules/Inventory/Services/DuplicateCheckService.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DuplicateCheckService
|
||||
{
|
||||
/**
|
||||
* 檢查疑似重複進貨
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function checkDuplicateReceipt(array $data): array
|
||||
{
|
||||
$warnings = [];
|
||||
$vendorId = isset($data['vendor_id']) && $data['vendor_id'] !== '' ? (int)$data['vendor_id'] : null;
|
||||
$poId = $data['purchase_order_id'] ?? null;
|
||||
$items = $data['items'] ?? [];
|
||||
$daysRange = 7; // 近期範圍天數
|
||||
|
||||
// 1. 同 PO 重複檢查 (僅限標準採購)
|
||||
if ($data['type'] === 'standard' && $poId) {
|
||||
$existingReceipts = GoodsReceipt::where('purchase_order_id', $poId)
|
||||
->where('status', '!=', 'cancelled')
|
||||
->withCount('items')
|
||||
->get();
|
||||
|
||||
if ($existingReceipts->isNotEmpty()) {
|
||||
$warnings[] = [
|
||||
'level' => 'high',
|
||||
'type' => 'same_po',
|
||||
'title' => '同一採購單已有進貨紀錄',
|
||||
'message' => "此採購單已被引用於 {$existingReceipts->count()} 筆進貨單中,請確認是否為分批交貨。",
|
||||
'related_receipts' => $existingReceipts->map(fn($r) => [
|
||||
'id' => $r->id,
|
||||
'code' => $r->code,
|
||||
'received_date' => $r->received_date->format('Y-m-d'),
|
||||
'status' => match($r->status) {
|
||||
'completed' => '已完成',
|
||||
'draft' => '草稿',
|
||||
'pending_audit' => '待審核',
|
||||
'rejected' => '已退回',
|
||||
default => $r->status
|
||||
},
|
||||
'item_count' => $r->items_count,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 近期同品項檢查 (針對每一個 item)
|
||||
$duplicatedItems = [];
|
||||
$recentDate = Carbon::now()->subDays($daysRange);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$productId = $item['product_id'];
|
||||
$qty = (float)$item['quantity_received'];
|
||||
|
||||
// 尋找近期同供應商、同品項的進貨紀錄
|
||||
$recentHits = GoodsReceiptItem::whereHas('goodsReceipt', function($query) use ($vendorId, $recentDate) {
|
||||
$query->where('vendor_id', $vendorId)
|
||||
->where('received_date', '>=', $recentDate)
|
||||
->where('status', '!=', 'cancelled');
|
||||
})
|
||||
->where('product_id', $productId)
|
||||
->with(['goodsReceipt:id,code,received_date'])
|
||||
->get();
|
||||
|
||||
foreach ($recentHits as $hit) {
|
||||
$hitQty = (float)$hit->quantity_received;
|
||||
|
||||
// 如果數量完全相同,視為高風險
|
||||
$isExactMatch = abs($hitQty - $qty) < 0.001;
|
||||
|
||||
$duplicatedItems[] = [
|
||||
'product_id' => $productId,
|
||||
'product_name' => $item['product_name'] ?? '未知商品',
|
||||
'last_receipt_code' => $hit->goodsReceipt->code,
|
||||
'last_receipt_date' => $hit->goodsReceipt->received_date->format('Y-m-d'),
|
||||
'last_quantity' => $hitQty,
|
||||
'current_quantity' => $qty,
|
||||
'is_high_risk' => $isExactMatch
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($duplicatedItems)) {
|
||||
// 按商品分組顯示
|
||||
$warnings[] = [
|
||||
'level' => collect($duplicatedItems)->contains('is_high_risk', true) ? 'high' : 'medium',
|
||||
'type' => 'recent_duplicate_product',
|
||||
'title' => '近期同供應商商品進貨紀錄',
|
||||
'message' => "偵測到以下商品在近 {$daysRange} 天內有過進貨紀錄,請確認是否重複收貨。",
|
||||
'duplicated_items' => $duplicatedItems,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. 長期未調整單價檢查 (90 天內同供應商、同品項單價未變動)
|
||||
$stalePriceItems = $this->checkStalePrice($items, $vendorId);
|
||||
if (!empty($stalePriceItems)) {
|
||||
$warnings[] = [
|
||||
'level' => 'medium',
|
||||
'type' => 'stale_price',
|
||||
'title' => '長期未調整進貨單價',
|
||||
'message' => '以下商品的進貨單價在過去 90 天內未曾變動,建議確認是否需要重新議價。',
|
||||
'stale_items' => $stalePriceItems,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'has_warnings' => !empty($warnings),
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查長期未調整單價的品項
|
||||
*
|
||||
* 邏輯:針對每個品項,查詢同供應商在過去 90 天內的所有進貨單價。
|
||||
* 若所有紀錄的單價皆相同,且至少有 1 筆以上紀錄,則視為「未調整」。
|
||||
*
|
||||
* @param array $items 本次進貨的品項列表
|
||||
* @param mixed $vendorId 供應商 ID
|
||||
* @return array
|
||||
*/
|
||||
private function checkStalePrice(array $items, $vendorId): array
|
||||
{
|
||||
// dump("Entering checkStalePrice", count($items), $vendorId);
|
||||
$staleDays = 90;
|
||||
$sinceDate = Carbon::now()->subDays($staleDays);
|
||||
$staleItems = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$productId = (int) $item['product_id'];
|
||||
$currentPrice = (float) ($item['unit_price'] ?? 0);
|
||||
|
||||
// 查詢過去 90 天內的所有進貨單品項
|
||||
$query = GoodsReceiptItem::whereHas('goodsReceipt', function ($query) use ($vendorId, $sinceDate) {
|
||||
if ($vendorId) {
|
||||
$query->where('vendor_id', $vendorId);
|
||||
}
|
||||
$query->where('received_date', '>=', $sinceDate->format('Y-m-d'))
|
||||
->where('status', '!=', 'cancelled');
|
||||
})
|
||||
->where('product_id', $productId)
|
||||
->with(['goodsReceipt:id,code,received_date'])
|
||||
->orderBy('id', 'desc');
|
||||
|
||||
$historicalPrices = $query->get();
|
||||
|
||||
// dump("Prod $productId count: " . $historicalPrices->count());
|
||||
|
||||
// 至少需要 1 筆歷史紀錄
|
||||
if ($historicalPrices->count() < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 檢查所有歷史單價是否完全相同 (排除 Decimal 物件比對問題)
|
||||
$prices = $historicalPrices->map(fn($h) => round((float)$h->unit_price, 2));
|
||||
$distinctPricesCount = $prices->unique()->count();
|
||||
|
||||
if ($distinctPricesCount > 1) {
|
||||
continue; // 歷史上有變動過
|
||||
}
|
||||
|
||||
$historicalPrice = $prices->first();
|
||||
|
||||
// 本次單價也跟歷史相同 (容許小數差距)
|
||||
if (round($currentPrice, 2) === round($historicalPrice, 2)) {
|
||||
$oldestReceipt = $historicalPrices->last();
|
||||
$newestReceipt = $historicalPrices->first();
|
||||
|
||||
$staleItems[] = [
|
||||
'product_id' => $productId,
|
||||
'product_name' => $item['product_name'] ?? '未知商品',
|
||||
'unit_price' => $currentPrice,
|
||||
'record_count' => $historicalPrices->count(),
|
||||
'earliest_date' => $oldestReceipt->goodsReceipt->received_date->format('Y-m-d'),
|
||||
'latest_date' => $newestReceipt->goodsReceipt->received_date->format('Y-m-d'),
|
||||
'latest_code' => $newestReceipt->goodsReceipt->code,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $staleItems;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GoodsReceiptService
|
||||
class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
@@ -22,7 +24,7 @@ class GoodsReceiptService
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Goods Receipt and process inventory.
|
||||
* Store a new Goods Receipt (Draft state).
|
||||
*
|
||||
* @param array $data
|
||||
* @return GoodsReceipt
|
||||
@@ -34,26 +36,245 @@ class GoodsReceiptService
|
||||
// 1. Generate Code
|
||||
$data['code'] = $this->generateCode($data['received_date']);
|
||||
$data['user_id'] = auth()->id();
|
||||
$data['status'] = 'completed'; // Direct completion for now
|
||||
$data['status'] = GoodsReceipt::STATUS_DRAFT; // 預設草稿
|
||||
|
||||
// 2. Create Header
|
||||
$goodsReceipt = GoodsReceipt::create($data);
|
||||
// 2. 靜默建立以抑制自動日誌(後續手動發送含品項明細的日誌)
|
||||
$goodsReceipt = new GoodsReceipt($data);
|
||||
$goodsReceipt->saveQuietly();
|
||||
|
||||
// 3. Process Items
|
||||
// 3. 建立品項並收集 items_diff
|
||||
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
||||
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
$itemsToInsert = [];
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// Create GR Item
|
||||
$grItem = new GoodsReceiptItem([
|
||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
|
||||
? (float) $itemData['subtotal']
|
||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||
|
||||
$itemsToInsert[] = [
|
||||
'goods_receipt_id' => $goodsReceipt->id,
|
||||
'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'],
|
||||
'total_amount' => $totalAmount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
$goodsReceipt->items()->save($grItem);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// 4. Update Inventory
|
||||
$product = $products->get($itemData['product_id']);
|
||||
$diff['added'][] = [
|
||||
'product_name' => $product?->name ?? '未知商品',
|
||||
'new' => [
|
||||
'quantity_received' => (float)$itemData['quantity_received'],
|
||||
'unit_price' => (float)$itemData['unit_price'],
|
||||
'total_amount' => (float)$totalAmount,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($itemsToInsert)) {
|
||||
GoodsReceiptItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
// 4. 手動發送高品質日誌(包含品項明細)
|
||||
activity()
|
||||
->performedOn($goodsReceipt)
|
||||
->causedBy(auth()->user())
|
||||
->event('created')
|
||||
->withProperties([
|
||||
'items_diff' => $diff,
|
||||
'attributes' => [
|
||||
'gr_number' => $goodsReceipt->code,
|
||||
'type' => $goodsReceipt->type,
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'vendor_id' => $goodsReceipt->vendor_id,
|
||||
'purchase_order_id' => $goodsReceipt->purchase_order_id,
|
||||
'received_date' => $goodsReceipt->received_date,
|
||||
'status' => $goodsReceipt->status,
|
||||
'remarks' => $goodsReceipt->remarks,
|
||||
'user_id' => $goodsReceipt->user_id,
|
||||
]
|
||||
])
|
||||
->log('created');
|
||||
|
||||
return $goodsReceipt;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(GoodsReceipt $goodsReceipt, array $data)
|
||||
{
|
||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
||||
throw new \Exception('只有草稿或被退回的進貨單可以修改。');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($goodsReceipt, $data) {
|
||||
$goodsReceipt->fill([
|
||||
'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id,
|
||||
'received_date' => $data['received_date'] ?? $goodsReceipt->received_date,
|
||||
'remarks' => $data['remarks'] ?? $goodsReceipt->remarks,
|
||||
]);
|
||||
|
||||
$dirty = $goodsReceipt->getDirty();
|
||||
$oldAttributes = [];
|
||||
$newAttributes = [];
|
||||
|
||||
foreach ($dirty as $key => $value) {
|
||||
$oldAttributes[$key] = $goodsReceipt->getOriginal($key);
|
||||
$newAttributes[$key] = $value;
|
||||
}
|
||||
|
||||
// 儲存但不觸發事件,以避免重複記錄
|
||||
$goodsReceipt->saveQuietly();
|
||||
|
||||
// 捕捉包含商品名稱的舊項目以進行比對
|
||||
$oldItemsCollection = $goodsReceipt->items()->get();
|
||||
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
|
||||
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
|
||||
|
||||
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
|
||||
$product = $oldProducts->get($item->product_id);
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity_received' => (float) $item->quantity_received,
|
||||
'unit_price' => (float) $item->unit_price,
|
||||
'total_amount' => (float) $item->total_amount,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
if (isset($data['items'])) {
|
||||
$goodsReceipt->items()->delete();
|
||||
|
||||
$itemsToInsert = [];
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
|
||||
? (float) $itemData['subtotal']
|
||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||
|
||||
$itemsToInsert[] = [
|
||||
'goods_receipt_id' => $goodsReceipt->id,
|
||||
'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' => $totalAmount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($itemsToInsert)) {
|
||||
GoodsReceiptItem::insert($itemsToInsert);
|
||||
}
|
||||
}
|
||||
|
||||
// 計算項目差異
|
||||
$itemDiffs = [
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
$newItemsCollection = $goodsReceipt->items()->get();
|
||||
$newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray();
|
||||
$newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id');
|
||||
|
||||
$newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) {
|
||||
$product = $newProducts->get($item->product_id);
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity_received' => (float) $item->quantity_received,
|
||||
'unit_price' => (float) $item->unit_price,
|
||||
'total_amount' => (float) $item->total_amount,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
foreach ($oldItems as $productId => $oldItem) {
|
||||
if (!$newItemsFormatted->has($productId)) {
|
||||
$itemDiffs['removed'][] = $oldItem;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($newItemsFormatted as $productId => $newItem) {
|
||||
if (!$oldItems->has($productId)) {
|
||||
$itemDiffs['added'][] = $newItem;
|
||||
} else {
|
||||
$oldItem = $oldItems[$productId];
|
||||
if (
|
||||
$oldItem['quantity_received'] != $newItem['quantity_received'] ||
|
||||
$oldItem['unit_price'] != $newItem['unit_price'] ||
|
||||
$oldItem['total_amount'] != $newItem['total_amount']
|
||||
) {
|
||||
$itemDiffs['updated'][] = [
|
||||
'product_name' => $newItem['product_name'],
|
||||
'old' => [
|
||||
'quantity_received' => $oldItem['quantity_received'],
|
||||
'unit_price' => $oldItem['unit_price'],
|
||||
'total_amount' => $oldItem['total_amount'],
|
||||
],
|
||||
'new' => [
|
||||
'quantity_received' => $newItem['quantity_received'],
|
||||
'unit_price' => $newItem['unit_price'],
|
||||
'total_amount' => $newItem['total_amount'],
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有變更,手動觸發單一合併日誌
|
||||
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
|
||||
activity()
|
||||
->performedOn($goodsReceipt)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => $newAttributes,
|
||||
'old' => $oldAttributes,
|
||||
'items_diff' => $itemDiffs,
|
||||
])
|
||||
->log('updated');
|
||||
}
|
||||
|
||||
return $goodsReceipt->fresh('items');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit for audit (Confirm receipt by warehouse staff).
|
||||
* This will increase inventory and update PO.
|
||||
*
|
||||
* @param GoodsReceipt $goodsReceipt
|
||||
* @return GoodsReceipt
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function submit(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
return DB::transaction(function () use ($goodsReceipt) {
|
||||
// Pessimistic locking to prevent double submission
|
||||
$goodsReceipt = GoodsReceipt::lockForUpdate()->find($goodsReceipt->id);
|
||||
|
||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
||||
throw new \Exception('只有草稿或被退回的進貨單可以確認點收。');
|
||||
}
|
||||
|
||||
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
|
||||
$goodsReceipt->save();
|
||||
|
||||
// Process Inventory and PO updates
|
||||
foreach ($goodsReceipt->items as $grItem) {
|
||||
// 1. Update Inventory
|
||||
$reason = match($goodsReceipt->type) {
|
||||
'standard' => '採購進貨',
|
||||
'miscellaneous' => '雜項入庫',
|
||||
@@ -61,10 +282,24 @@ class GoodsReceiptService
|
||||
default => '進貨入庫',
|
||||
};
|
||||
|
||||
$quantityToRecord = $grItem->quantity_received;
|
||||
|
||||
// 單位換算邏輯:僅針對標準採購且有連結 PO Item 時
|
||||
if ($goodsReceipt->type === 'standard' && $grItem->purchase_order_item_id) {
|
||||
$poItem = \App\Modules\Procurement\Models\PurchaseOrderItem::find($grItem->purchase_order_item_id);
|
||||
$product = $this->inventoryService->getProduct($grItem->product_id);
|
||||
|
||||
if ($poItem && $product && $poItem->unit_id && $product->large_unit_id && $poItem->unit_id == $product->large_unit_id) {
|
||||
// 如果使用的是大單位,則換算為基本單位數量
|
||||
$quantityToRecord = $grItem->quantity_received * ($product->conversion_rate ?: 1);
|
||||
Log::info("Goods Receipt [{$goodsReceipt->code}] converted quantity for product [{$product->id}]: {$grItem->quantity_received} large unit -> {$quantityToRecord} base unit.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->inventoryService->createInventoryRecord([
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'product_id' => $grItem->product_id,
|
||||
'quantity' => $grItem->quantity_received,
|
||||
'quantity' => $quantityToRecord,
|
||||
'unit_cost' => $grItem->unit_price,
|
||||
'batch_number' => $grItem->batch_number,
|
||||
'expiry_date' => $grItem->expiry_date,
|
||||
@@ -75,8 +310,9 @@ class GoodsReceiptService
|
||||
'arrival_date' => $goodsReceipt->received_date,
|
||||
]);
|
||||
|
||||
// 5. Update PO if linked and type is standard
|
||||
// 2. 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
|
||||
@@ -84,26 +320,60 @@ class GoodsReceiptService
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event to let Finance module create AP
|
||||
event(new GoodsReceiptApprovedEvent($goodsReceipt->id));
|
||||
|
||||
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;
|
||||
private function generateCode(string $date): string
|
||||
{
|
||||
// 使用 Cache Lock 防止併發時產生重複單號
|
||||
$lock = \Illuminate\Support\Facades\Cache::lock('gr_code_generation', 10);
|
||||
|
||||
if (!$lock->get()) {
|
||||
throw new \Exception('系統忙碌中,進貨單號生成失敗,請稍後再試');
|
||||
}
|
||||
|
||||
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
try {
|
||||
// Format: GR-YYYYMMDD-NN
|
||||
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
|
||||
|
||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
|
||||
if ($last) {
|
||||
$seq = intval(substr($last->code, -2)) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
$code = $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
|
||||
return $code;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取指定的進貨單資訊 (實作 GoodsReceiptServiceInterface)
|
||||
*
|
||||
* @param int $goodsReceiptId
|
||||
* @return array|null
|
||||
*/
|
||||
public function getGoodsReceiptData(int $goodsReceiptId): ?array
|
||||
{
|
||||
$receipt = GoodsReceipt::with('items')->find($goodsReceiptId);
|
||||
|
||||
if (!$receipt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 以陣列形式回傳資料,避免外部模組產生 Model 依賴
|
||||
return $receipt->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ class InventoryReportService
|
||||
* @param int|null $perPage 每頁筆數
|
||||
* @return \Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Support\Collection
|
||||
*/
|
||||
public function getReportData(array $filters, ?int $perPage = 10)
|
||||
public function getReportData(array $filters, ?int $perPage = null)
|
||||
{
|
||||
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
@@ -197,8 +198,9 @@ class InventoryReportService
|
||||
/**
|
||||
* 取得特定商品的庫存異動明細
|
||||
*/
|
||||
public function getProductDetails($productId, array $filters, ?int $perPage = 20)
|
||||
public function getProductDetails($productId, array $filters, ?int $perPage = null)
|
||||
{
|
||||
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
|
||||
@@ -6,23 +6,46 @@ 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
|
||||
{
|
||||
public function getAllWarehouses()
|
||||
{
|
||||
return Warehouse::all();
|
||||
return Warehouse::select('id', 'name', 'code', 'type')->get();
|
||||
}
|
||||
|
||||
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
|
||||
{
|
||||
return Inventory::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($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection
|
||||
{
|
||||
return Inventory::where('quantity', '>', 0)
|
||||
->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
|
||||
->orderBy('expiry_date', 'asc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getAllProducts()
|
||||
{
|
||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
||||
return Product::select('id', 'name', 'code', 'base_unit_id', 'large_unit_id')
|
||||
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getUnits()
|
||||
{
|
||||
return \App\Modules\Inventory\Models\Unit::all();
|
||||
return \App\Modules\Inventory\Models\Unit::select('id', 'name')->get();
|
||||
}
|
||||
|
||||
public function getInventoriesByIds(array $ids, array $with = [])
|
||||
@@ -40,6 +63,11 @@ class InventoryService implements InventoryServiceInterface
|
||||
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getWarehousesByCodes(array $codes)
|
||||
{
|
||||
return Warehouse::whereIn('code', $codes)->get();
|
||||
}
|
||||
|
||||
public function getProductsByName(string $name)
|
||||
{
|
||||
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
|
||||
@@ -59,9 +87,9 @@ class InventoryService implements InventoryServiceInterface
|
||||
return $stock >= $quantity;
|
||||
}
|
||||
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void
|
||||
{
|
||||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) {
|
||||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId) {
|
||||
$query = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->where('quantity', '>', 0);
|
||||
@@ -70,7 +98,8 @@ class InventoryService implements InventoryServiceInterface
|
||||
$query->where('location', $slot);
|
||||
}
|
||||
|
||||
$inventories = $query->orderBy('arrival_date', 'asc')
|
||||
$inventories = $query->lockForUpdate()
|
||||
->orderBy('arrival_date', 'asc')
|
||||
->get();
|
||||
|
||||
$remainingToDecrease = $quantity;
|
||||
@@ -79,7 +108,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
if ($remainingToDecrease <= 0) break;
|
||||
|
||||
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
||||
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason);
|
||||
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
|
||||
$remainingToDecrease -= $decreaseAmount;
|
||||
}
|
||||
|
||||
@@ -110,7 +139,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
]);
|
||||
}
|
||||
|
||||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason);
|
||||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId);
|
||||
} else {
|
||||
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
||||
}
|
||||
@@ -155,11 +184,11 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
// 更新其他可能變更的欄位 (如最後入庫日)
|
||||
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
|
||||
$inventory->save();
|
||||
$inventory->saveQuietly();
|
||||
} else {
|
||||
// 若不存在,則建立新紀錄
|
||||
$unitCost = $data['unit_cost'] ?? 0;
|
||||
$inventory = Inventory::create([
|
||||
$inventory = new Inventory([
|
||||
'warehouse_id' => $data['warehouse_id'],
|
||||
'product_id' => $data['product_id'],
|
||||
'quantity' => $data['quantity'],
|
||||
@@ -173,9 +202,10 @@ class InventoryService implements InventoryServiceInterface
|
||||
'quality_status' => $data['quality_status'] ?? 'normal',
|
||||
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
|
||||
]);
|
||||
$inventory->saveQuietly();
|
||||
}
|
||||
|
||||
\App\Modules\Inventory\Models\InventoryTransaction::create([
|
||||
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
|
||||
'inventory_id' => $inventory->id,
|
||||
'type' => '入庫',
|
||||
'quantity' => $data['quantity'],
|
||||
@@ -188,6 +218,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
'user_id' => auth()->id(),
|
||||
'actual_time' => now(),
|
||||
]);
|
||||
$transaction->saveQuietly();
|
||||
|
||||
return $inventory;
|
||||
});
|
||||
@@ -199,13 +230,12 @@ class InventoryService implements InventoryServiceInterface
|
||||
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
|
||||
$balanceBefore = $inventory->quantity;
|
||||
|
||||
$inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
|
||||
// 需要手動更新總價值
|
||||
$inventory->refresh();
|
||||
// 手動更新以配合 saveQuietly 消除日誌
|
||||
$inventory->quantity -= $quantity;
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
$inventory->saveQuietly();
|
||||
|
||||
\App\Modules\Inventory\Models\InventoryTransaction::create([
|
||||
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
|
||||
'inventory_id' => $inventory->id,
|
||||
'type' => '出庫',
|
||||
'quantity' => -$quantity,
|
||||
@@ -218,6 +248,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
'user_id' => auth()->id(),
|
||||
'actual_time' => now(),
|
||||
]);
|
||||
$transaction->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,10 +263,12 @@ class InventoryService implements InventoryServiceInterface
|
||||
/**
|
||||
* 即時庫存查詢:統計卡片 + 分頁明細
|
||||
*/
|
||||
public function getStockQueryData(array $filters = [], int $perPage = 10): array
|
||||
public function getStockQueryData(array $filters = [], ?int $perPage = null): array
|
||||
{
|
||||
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
$expiryDays = \App\Modules\Core\Models\SystemSetting::getVal('inventory.expiry_warning_days', 30);
|
||||
$expiryThreshold = now()->addDays($expiryDays)->toDateString();
|
||||
|
||||
// 基礎查詢
|
||||
$query = Inventory::query()
|
||||
@@ -273,12 +306,14 @@ class InventoryService implements InventoryServiceInterface
|
||||
$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}%");
|
||||
->orWhere('products.name', 'like', "%{$search}%")
|
||||
->orWhere('inventories.batch_number', 'like', "%{$search}%")
|
||||
->orWhere(\Illuminate\Support\Facades\DB::raw("CONCAT('BATCH-', inventories.id)"), 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -466,7 +501,8 @@ class InventoryService implements InventoryServiceInterface
|
||||
public function getDashboardStats(): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
$expiryDays = \App\Modules\Core\Models\SystemSetting::getVal('inventory.expiry_warning_days', 30);
|
||||
$expiryThreshold = now()->addDays($expiryDays)->toDateString();
|
||||
|
||||
// 1. 庫存品項數 (明細總數)
|
||||
$totalItems = DB::table('inventories')
|
||||
@@ -584,8 +620,231 @@ class InventoryService implements InventoryServiceInterface
|
||||
'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',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得特定倉庫代碼的所屬商品總庫存 (給 POS/外部系統同步使用)
|
||||
*
|
||||
* @param string $code
|
||||
* @return \Illuminate\Support\Collection|null
|
||||
*/
|
||||
public function getPosInventoryByWarehouseCode(string $code)
|
||||
{
|
||||
$warehouse = Warehouse::where('code', $code)->first();
|
||||
|
||||
if (!$warehouse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 整理該倉庫的庫存,以 product_id 進行 GROUP BY 並加總 quantity
|
||||
return DB::table('inventories')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->where('inventories.warehouse_id', $warehouse->id)
|
||||
->whereNull('inventories.deleted_at')
|
||||
->whereNull('products.deleted_at')
|
||||
->select(
|
||||
'products.external_pos_id',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
DB::raw('SUM(inventories.quantity) as total_quantity')
|
||||
)
|
||||
->groupBy('inventories.product_id', 'products.external_pos_id', 'products.code', 'products.name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function processIncomingInventory(Warehouse $warehouse, array $items, array $meta): void
|
||||
{
|
||||
DB::transaction(function () use ($warehouse, $items, $meta) {
|
||||
foreach ($items as $item) {
|
||||
$inventory = null;
|
||||
|
||||
if ($item['batchMode'] === 'existing') {
|
||||
// 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加)
|
||||
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
|
||||
// 更新成本 (若有傳入)
|
||||
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' => $meta['inboundDate'],
|
||||
'origin_country' => 'TW',
|
||||
]
|
||||
);
|
||||
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
} else {
|
||||
// 模式 B:建立新批號
|
||||
$originCountry = $item['originCountry'] ?? 'TW';
|
||||
$product = Product::find($item['productId']);
|
||||
|
||||
$batchNumber = Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
$originCountry,
|
||||
$meta['inboundDate']
|
||||
);
|
||||
|
||||
// 檢查是否存在
|
||||
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
|
||||
[
|
||||
'product_id' => $item['productId'],
|
||||
'batch_number' => $batchNumber
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $item['unit_cost'] ?? 0,
|
||||
'total_value' => 0,
|
||||
'location' => $item['location'] ?? null,
|
||||
'arrival_date' => $meta['inboundDate'],
|
||||
'expiry_date' => $item['expiryDate'] ?? null,
|
||||
'origin_country' => $originCountry,
|
||||
]
|
||||
);
|
||||
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
}
|
||||
|
||||
$currentQty = $inventory->quantity;
|
||||
$newQty = $currentQty + $item['quantity'];
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
// 更新總價值
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
$inventory->saveQuietly();
|
||||
|
||||
// 寫入異動紀錄
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動入庫',
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $meta['reason'] . (!empty($meta['notes']) ? ' - ' . $meta['notes'] : ''),
|
||||
'actual_time' => $meta['inboundDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function adjustInventory(Inventory $inventory, array $data): void
|
||||
{
|
||||
DB::transaction(function () use ($inventory, $data) {
|
||||
$currentQty = (float) $inventory->quantity;
|
||||
$newQty = (float) $data['quantity'];
|
||||
|
||||
$isAdjustment = isset($data['operation']);
|
||||
$changeQty = 0;
|
||||
|
||||
if ($isAdjustment) {
|
||||
switch ($data['operation']) {
|
||||
case 'add':
|
||||
$changeQty = (float) $data['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'subtract':
|
||||
$changeQty = -(float) $data['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'set':
|
||||
$changeQty = $newQty - $currentQty;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$changeQty = $newQty - $currentQty;
|
||||
}
|
||||
|
||||
if (isset($data['unit_cost'])) {
|
||||
$inventory->unit_cost = $data['unit_cost'];
|
||||
}
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
$inventory->saveQuietly();
|
||||
|
||||
$type = $data['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
|
||||
$typeMapping = [
|
||||
'manual_adjustment' => '手動調整庫存',
|
||||
'adjustment' => '盤點調整',
|
||||
'purchase_in' => '採購進貨',
|
||||
'sales_out' => '銷售出庫',
|
||||
'return_in' => '退貨入庫',
|
||||
'return_out' => '退貨出庫',
|
||||
'transfer_in' => '撥補入庫',
|
||||
'transfer_out' => '撥補出庫',
|
||||
];
|
||||
$chineseType = $typeMapping[$type] ?? $type;
|
||||
|
||||
if (!$isAdjustment && !isset($data['type'])) {
|
||||
$chineseType = '手動編輯';
|
||||
}
|
||||
|
||||
$reason = $data['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
|
||||
if (!empty($data['notes'])) {
|
||||
$reason .= ' - ' . $data['notes'];
|
||||
}
|
||||
|
||||
if (abs($changeQty) > 0.0001) {
|
||||
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
|
||||
'inventory_id' => $inventory->id,
|
||||
'type' => $chineseType,
|
||||
'quantity' => $changeQty,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $reason,
|
||||
'actual_time' => now(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
$transaction->saveQuietly();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
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
|
||||
class ProductService implements ProductServiceInterface
|
||||
{
|
||||
/**
|
||||
* Upsert product from external POS source.
|
||||
@@ -40,13 +41,20 @@ class ProductService
|
||||
$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 (Default: 未分類)
|
||||
if (empty($product->category_id)) {
|
||||
// Handle Category — 每次同步都更新(若有傳入)
|
||||
if (!empty($data['category']) || empty($product->category_id)) {
|
||||
$categoryName = $data['category'] ?? '未分類';
|
||||
$category = Category::firstOrCreate(
|
||||
['name' => $categoryName],
|
||||
@@ -55,8 +63,8 @@ class ProductService
|
||||
$product->category_id = $category->id;
|
||||
}
|
||||
|
||||
// Handle Base Unit (Default: 個)
|
||||
if (empty($product->base_unit_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;
|
||||
@@ -69,4 +77,105 @@ class ProductService
|
||||
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();
|
||||
}
|
||||
|
||||
public function createProduct(array $data)
|
||||
{
|
||||
if (empty($data['code'])) {
|
||||
$data['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
if (empty($data['barcode'])) {
|
||||
$data['barcode'] = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
return Product::create($data);
|
||||
}
|
||||
|
||||
public function updateProduct(Product $product, array $data)
|
||||
{
|
||||
if (empty($data['code'])) {
|
||||
$data['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
if (empty($data['barcode'])) {
|
||||
$data['barcode'] = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
$product->update($data);
|
||||
return $product;
|
||||
}
|
||||
|
||||
public function generateRandomCode()
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
public function generateRandomBarcode()
|
||||
{
|
||||
$barcode = '';
|
||||
|
||||
do {
|
||||
$barcode = '';
|
||||
for ($i = 0; $i < 13; $i++) {
|
||||
$barcode .= rand(0, 9);
|
||||
}
|
||||
} while (Product::where('barcode', $barcode)->exists());
|
||||
|
||||
return $barcode;
|
||||
}
|
||||
|
||||
public function findByBarcodeOrCode(?string $barcode, ?string $code)
|
||||
{
|
||||
$product = null;
|
||||
if (!empty($barcode)) {
|
||||
$product = Product::where('barcode', $barcode)->first();
|
||||
}
|
||||
if (!$product && !empty($code)) {
|
||||
$product = Product::where('code', $code)->first();
|
||||
}
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
|
||||
508
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
508
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
@@ -0,0 +1,508 @@
|
||||
<?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, bool $submitImmediately = false): StoreRequisition
|
||||
{
|
||||
return DB::transaction(function () use ($data, $items, $userId, $submitImmediately) {
|
||||
$requisition = new StoreRequisition([
|
||||
'store_warehouse_id' => $data['store_warehouse_id'],
|
||||
'status' => $submitImmediately ? 'pending' : 'draft',
|
||||
'submitted_at' => $submitImmediately ? now() : null,
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
// 手動產生單號,因為 saveQuietly 會繞過模型事件
|
||||
if (empty($requisition->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'SR-' . $today . '-';
|
||||
$lastDoc = StoreRequisition::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
$requisition->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
|
||||
// 靜默建立以抑制自動日誌
|
||||
$requisition->saveQuietly();
|
||||
|
||||
$itemsToInsert = [];
|
||||
$productIds = collect($items)->pluck('product_id')->unique()->toArray();
|
||||
$products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||
|
||||
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
||||
foreach ($items as $item) {
|
||||
$itemsToInsert[] = [
|
||||
'store_requisition_id' => $requisition->id,
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$product = $products->get($item['product_id']);
|
||||
$diff['added'][] = [
|
||||
'product_name' => $product?->name ?? '未知商品',
|
||||
'new' => [
|
||||
'quantity' => (float)$item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]
|
||||
];
|
||||
}
|
||||
StoreRequisitionItem::insert($itemsToInsert);
|
||||
|
||||
// 如果需直接提交,觸發通知
|
||||
if ($submitImmediately) {
|
||||
$this->notifyApprovers($requisition, 'submitted', $userId);
|
||||
}
|
||||
|
||||
// 手動發送高品質日誌
|
||||
activity()
|
||||
->performedOn($requisition)
|
||||
->causedBy($userId)
|
||||
->event('created')
|
||||
->withProperties([
|
||||
'items_diff' => $diff,
|
||||
'attributes' => [
|
||||
'doc_no' => $requisition->doc_no,
|
||||
'store_warehouse_id' => $requisition->store_warehouse_id,
|
||||
'status' => $requisition->status,
|
||||
'remark' => $requisition->remark,
|
||||
'created_by' => $requisition->created_by,
|
||||
'submitted_at' => $requisition->submitted_at,
|
||||
]
|
||||
])
|
||||
->log('created');
|
||||
|
||||
return $requisition->load('items');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新叫貨單(僅限 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) {
|
||||
// 擷取舊狀態供日誌對照
|
||||
$oldAttributes = [
|
||||
'store_warehouse_id' => $requisition->store_warehouse_id,
|
||||
'remark' => $requisition->remark,
|
||||
];
|
||||
|
||||
// 手動更新屬性
|
||||
$requisition->store_warehouse_id = $data['store_warehouse_id'];
|
||||
$requisition->remark = $data['remark'] ?? null;
|
||||
$requisition->reject_reason = null; // 清除駁回原因
|
||||
|
||||
// 品項對比邏輯
|
||||
$oldItems = $requisition->items()->with('product:id,name')->get();
|
||||
$oldItemsMap = $oldItems->keyBy('product_id');
|
||||
$newItemsMap = collect($items)->keyBy('product_id');
|
||||
|
||||
$diff = [
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// 1. 處理更新與新增
|
||||
foreach ($items as $itemData) {
|
||||
$productId = $itemData['product_id'];
|
||||
$newQty = (float)$itemData['requested_qty'];
|
||||
$newRemark = $itemData['remark'] ?? null;
|
||||
|
||||
if ($oldItemsMap->has($productId)) {
|
||||
$oldItem = $oldItemsMap->get($productId);
|
||||
if ((float)$oldItem->requested_qty !== $newQty || $oldItem->remark !== $newRemark) {
|
||||
$diff['updated'][] = [
|
||||
'product_name' => $oldItem->product?->name ?? '未知商品',
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->requested_qty,
|
||||
'remark' => $oldItem->remark,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => $newQty,
|
||||
'remark' => $newRemark,
|
||||
]
|
||||
];
|
||||
}
|
||||
$oldItemsMap->forget($productId);
|
||||
} else {
|
||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
||||
$diff['added'][] = [
|
||||
'product_name' => $product?->name ?? '未知商品',
|
||||
'new' => [
|
||||
'quantity' => $newQty,
|
||||
'remark' => $newRemark,
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 處理移除
|
||||
foreach ($oldItemsMap as $productId => $oldItem) {
|
||||
$diff['removed'][] = [
|
||||
'product_name' => $oldItem->product?->name ?? '未知商品',
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->requested_qty,
|
||||
'remark' => $oldItem->remark,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// 儲存實際變動
|
||||
$requisition->items()->delete();
|
||||
$itemsToInsert = [];
|
||||
foreach ($items as $item) {
|
||||
$itemsToInsert[] = [
|
||||
'store_requisition_id' => $requisition->id,
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
StoreRequisitionItem::insert($itemsToInsert);
|
||||
|
||||
// 檢查是否有任何變動 (主表或明細)
|
||||
$isDirty = $requisition->isDirty();
|
||||
$hasItemsDiff = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
||||
|
||||
if ($isDirty || $hasItemsDiff) {
|
||||
// 擷取新狀態
|
||||
$newAttributes = [
|
||||
'store_warehouse_id' => $requisition->store_warehouse_id,
|
||||
'remark' => $requisition->remark,
|
||||
];
|
||||
|
||||
// 靜默更新
|
||||
$requisition->saveQuietly();
|
||||
|
||||
// 手動發送紀錄
|
||||
activity()
|
||||
->performedOn($requisition)
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'items_diff' => $diff,
|
||||
'attributes' => $newAttributes,
|
||||
'old' => $oldAttributes
|
||||
])
|
||||
->log('updated');
|
||||
}
|
||||
|
||||
return $requisition->load('items');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交審核(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) {
|
||||
// 處理前端傳來的明細與批號資料
|
||||
$processedItems = []; // 暫存處理後的明細,用於轉入調撥單
|
||||
|
||||
if (isset($data['items'])) {
|
||||
$requisition->load('items.product');
|
||||
$reqItemMap = $requisition->items->keyBy('id');
|
||||
|
||||
foreach ($data['items'] as $itemData) {
|
||||
$reqItemId = $itemData['id'];
|
||||
$reqItem = $reqItemMap->get($reqItemId);
|
||||
$productName = $reqItem?->product?->name ?? '未知商品';
|
||||
$totalApprovedQty = 0;
|
||||
$batches = $itemData['batches'] ?? [];
|
||||
|
||||
// 如果有批號,根據批號展開。若有多個無批號(null)的批次(例如來自不同貨道),則將其數量加總
|
||||
if (!empty($batches)) {
|
||||
$batchGroups = [];
|
||||
foreach ($batches as $batch) {
|
||||
$qty = (float)($batch['qty'] ?? 0);
|
||||
$bNum = $batch['batch_number'] ?? null;
|
||||
$invId = $batch['inventory_id'] ?? null;
|
||||
|
||||
if ($qty > 0) {
|
||||
if ($invId) {
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::lockForUpdate()->find($invId);
|
||||
if ($inventory) {
|
||||
$available = max(0, $inventory->quantity - $inventory->reserved_quantity);
|
||||
if ($qty > $available) {
|
||||
$batchStr = $bNum ? "批號 {$bNum}" : "無批號";
|
||||
throw ValidationException::withMessages([
|
||||
'items' => "「{$productName}」的 {$batchStr} 數量({$qty})不可大於可用庫存({$available})",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$totalApprovedQty += $qty;
|
||||
$batchKey = $bNum ?? '';
|
||||
$batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($batchGroups as $bNumKey => $qty) {
|
||||
$processedItems[] = [
|
||||
'req_item_id' => $reqItemId,
|
||||
'batch_number' => $bNumKey === '' ? null : $bNumKey,
|
||||
'quantity' => $qty,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 無批號,傳統輸入
|
||||
$qty = (float)($itemData['approved_qty'] ?? 0);
|
||||
if ($qty > 0) {
|
||||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||||
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
||||
->where('product_id', $reqItem->product_id)
|
||||
->lockForUpdate() // 補上鎖定
|
||||
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
||||
->value('available') ?? 0;
|
||||
|
||||
if ($qty > $totalAvailable) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => "「{$productName}」的數量({$qty})不可大於供貨倉可用總庫存({$totalAvailable})",
|
||||
]);
|
||||
}
|
||||
|
||||
$totalApprovedQty += $qty;
|
||||
$processedItems[] = [
|
||||
'req_item_id' => $reqItemId,
|
||||
'batch_number' => null,
|
||||
'quantity' => $qty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 更新叫貨單明細的核准數量總和
|
||||
StoreRequisitionItem::where('id', $reqItemId)
|
||||
->where('store_requisition_id', $requisition->id)
|
||||
->update(['approved_qty' => $totalApprovedQty]);
|
||||
}
|
||||
}
|
||||
|
||||
// 優先使用傳入的供貨倉庫,若無則從單據中取得
|
||||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||||
|
||||
if (!$supplyWarehouseId) {
|
||||
throw ValidationException::withMessages([
|
||||
'supply_warehouse_id' => '請指定供貨倉庫',
|
||||
]);
|
||||
}
|
||||
|
||||
// 查詢供貨倉庫是否有預設在途倉
|
||||
$supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($supplyWarehouseId);
|
||||
$defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id;
|
||||
|
||||
// 產生調撥單(供貨倉庫 → 門市倉庫)
|
||||
$transferOrder = $this->transferService->createOrder(
|
||||
fromWarehouseId: $supplyWarehouseId,
|
||||
toWarehouseId: $requisition->store_warehouse_id,
|
||||
remarks: "由叫貨單 {$requisition->doc_no} 自動產生",
|
||||
userId: $userId,
|
||||
transitWarehouseId: $defaultTransitId,
|
||||
);
|
||||
|
||||
// 將核准的明細寫入調撥單
|
||||
$requisition->load('items');
|
||||
$transferItems = [];
|
||||
|
||||
// 建立 req_item_id 對應 product_id 的 lookup
|
||||
$reqItemMap = $requisition->items->keyBy('id');
|
||||
|
||||
foreach ($processedItems as $pItem) {
|
||||
$reqItem = $reqItemMap->get($pItem['req_item_id']);
|
||||
if ($reqItem) {
|
||||
$transferItems[] = [
|
||||
'product_id' => $reqItem->product_id,
|
||||
'batch_number' => $pItem['batch_number'],
|
||||
'quantity' => $pItem['quantity'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($transferItems)) {
|
||||
$this->transferService->updateItems($transferOrder, $transferItems);
|
||||
|
||||
// 手動發送調撥單的「已建立」合併日誌,包含初始明細
|
||||
activity()
|
||||
->performedOn($transferOrder)
|
||||
->causedBy($userId)
|
||||
->event('created')
|
||||
->withProperties(array_merge(
|
||||
['items_diff' => $transferOrder->activityProperties['items_diff'] ?? []],
|
||||
[
|
||||
'attributes' => [
|
||||
'doc_no' => $transferOrder->doc_no,
|
||||
'from_warehouse_id' => $transferOrder->from_warehouse_id,
|
||||
'to_warehouse_id' => $transferOrder->to_warehouse_id,
|
||||
'transit_warehouse_id' => $transferOrder->transit_warehouse_id,
|
||||
'remarks' => $transferOrder->remarks,
|
||||
'status' => $transferOrder->status,
|
||||
'created_by' => $transferOrder->created_by,
|
||||
]
|
||||
]
|
||||
))
|
||||
->log('created');
|
||||
}
|
||||
|
||||
// 更新叫貨單狀態
|
||||
$requisition->update([
|
||||
'status' => 'approved',
|
||||
'supply_warehouse_id' => $supplyWarehouseId,
|
||||
'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));
|
||||
}
|
||||
}
|
||||
}
|
||||
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TraceabilityService
|
||||
{
|
||||
public function __construct(
|
||||
protected ProductionServiceInterface $productionService,
|
||||
protected ProcurementServiceInterface $procurementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 逆向溯源:從成品批號往前追溯用到的所有原料與廠商
|
||||
*
|
||||
* @param string $batchNumber 成品批號
|
||||
* @return array 樹狀結構資料
|
||||
*/
|
||||
public function traceBackward(string $batchNumber): array
|
||||
{
|
||||
// 取得基本庫存資訊以作為根節點參考
|
||||
$baseInventory = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->first();
|
||||
|
||||
// 定義根節點
|
||||
$rootNode = [
|
||||
'id' => 'batch_' . $batchNumber,
|
||||
'type' => 'target_batch',
|
||||
'label' => '查詢批號: ' . $batchNumber,
|
||||
'batch_number' => $batchNumber,
|
||||
'product_name' => $baseInventory?->product?->name,
|
||||
'spec' => $baseInventory?->product?->spec,
|
||||
'warehouse_name' => $baseInventory?->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 1. 尋找這個批號是不是生產出來的成品 (Production Order Output)
|
||||
// 透過 ProductionService 獲取,以落實模組解耦
|
||||
$productionOrders = $this->productionService->getProductionOrdersByOutputBatch($batchNumber);
|
||||
|
||||
foreach ($productionOrders as $po) {
|
||||
$poNode = [
|
||||
'id' => 'po_' . $po->id,
|
||||
'type' => 'production_order',
|
||||
'label' => '生產工單: ' . $po->code,
|
||||
'date' => $po->production_date instanceof \DateTimeInterface
|
||||
? $po->production_date->format('Y-m-d')
|
||||
: $po->production_date,
|
||||
'quantity' => $po->output_quantity,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 針對每一張工單,尋找它投料的原料批號
|
||||
foreach ($po->items as $item) {
|
||||
if (isset($item->inventory)) {
|
||||
$materialNode = $this->buildMaterialBackwardNode($item->inventory, $item);
|
||||
$poNode['children'][] = $materialNode;
|
||||
}
|
||||
}
|
||||
$rootNode['children'][] = $poNode;
|
||||
}
|
||||
|
||||
// 2. 如果這批號是直接採購進來的 (Goods Receipt)
|
||||
// 或者是為了補足直接查詢原料批號的場景
|
||||
$inventories = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->get();
|
||||
|
||||
foreach ($inventories as $inv) {
|
||||
// 尋找進貨單
|
||||
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->where('product_id', $inv->product_id)
|
||||
->get();
|
||||
|
||||
foreach ($grItems as $grItem) {
|
||||
$gr = $grItem->goodsReceipt;
|
||||
if ($gr) {
|
||||
$grNode = [
|
||||
'id' => 'gr_' . $gr->id . '_' . $inv->id,
|
||||
'type' => 'goods_receipt',
|
||||
'label' => '進貨單: ' . $gr->code,
|
||||
'date' => $gr->received_date instanceof \DateTimeInterface
|
||||
? $gr->received_date->format('Y-m-d')
|
||||
: $gr->received_date,
|
||||
'vendor_id' => $gr->vendor_id,
|
||||
'quantity' => $grItem->quantity,
|
||||
'product_name' => $grItem->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 避免重複加入
|
||||
$isDuplicate = false;
|
||||
foreach ($rootNode['children'] as $child) {
|
||||
if ($child['id'] === $grNode['id']) {
|
||||
$isDuplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$isDuplicate) {
|
||||
$rootNode['children'][] = $grNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 補充廠商名稱 (跨模組)
|
||||
$this->hydrateVendorNames($rootNode);
|
||||
|
||||
return $rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立原料的逆向溯源節點
|
||||
*/
|
||||
private function buildMaterialBackwardNode(Inventory $inventory, $poItem = null): array
|
||||
{
|
||||
$node = [
|
||||
'id' => 'inv_' . $inventory->id,
|
||||
'type' => 'material_batch',
|
||||
'label' => '原料批號: ' . $inventory->batch_number,
|
||||
'product_name' => $inventory->product?->name,
|
||||
'spec' => $inventory->product?->spec,
|
||||
'batch_number' => $inventory->batch_number,
|
||||
'quantity' => $poItem ? $poItem->quantity_used : null,
|
||||
'warehouse_name' => $inventory->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 繼續往下追溯該原料是怎麼來的 (進貨單)
|
||||
if ($inventory->batch_number) {
|
||||
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
|
||||
->where('batch_number', $inventory->batch_number)
|
||||
->where('product_id', $inventory->product_id)
|
||||
->get();
|
||||
|
||||
foreach ($grItems as $grItem) {
|
||||
$gr = $grItem->goodsReceipt;
|
||||
if ($gr) {
|
||||
$node['children'][] = [
|
||||
'id' => 'gr_' . $gr->id,
|
||||
'type' => 'goods_receipt',
|
||||
'label' => '進貨單: ' . $gr->code,
|
||||
'date' => $gr->received_date instanceof \DateTimeInterface
|
||||
? $gr->received_date->format('Y-m-d')
|
||||
: $gr->received_date,
|
||||
'vendor_id' => $gr->vendor_id,
|
||||
'quantity' => $grItem->quantity,
|
||||
'product_name' => $grItem->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 順向追蹤:從原料批號往後追查被用在哪些成品及去向
|
||||
*
|
||||
* @param string $batchNumber 原料批號
|
||||
* @return array 樹狀結構資料
|
||||
*/
|
||||
public function traceForward(string $batchNumber): array
|
||||
{
|
||||
$baseInventory = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->first();
|
||||
|
||||
$rootNode = [
|
||||
'id' => 'batch_' . $batchNumber,
|
||||
'type' => 'source_batch',
|
||||
'label' => '查詢批號: ' . $batchNumber,
|
||||
'batch_number' => $batchNumber,
|
||||
'product_name' => $baseInventory?->product?->name,
|
||||
'spec' => $baseInventory?->product?->spec,
|
||||
'warehouse_name' => $baseInventory?->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 1. 尋找這個批號被哪些工單使用了
|
||||
$inventories = Inventory::with(['product', 'warehouse'])->where('batch_number', $batchNumber)->get();
|
||||
|
||||
foreach ($inventories as $inv) {
|
||||
// 透過 ProductionService 獲取,以落實模組解耦
|
||||
$poItems = $this->productionService->getProductionOrderItemsByInventoryId($inv->id, ['productionOrder']);
|
||||
|
||||
foreach ($poItems as $item) {
|
||||
$po = $item->productionOrder;
|
||||
if ($po) {
|
||||
$poNode = [
|
||||
'id' => 'po_' . $po->id,
|
||||
'type' => 'production_order',
|
||||
'label' => '投入工單: ' . $po->code,
|
||||
'date' => $po->production_date instanceof \DateTimeInterface
|
||||
? $po->production_date->format('Y-m-d')
|
||||
: $po->production_date,
|
||||
'quantity' => $item->quantity_used,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 該工單產出的成品批號
|
||||
if ($po->output_batch_number) {
|
||||
$outputInventory = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $po->output_batch_number)
|
||||
->first();
|
||||
|
||||
$outputNode = [
|
||||
'id' => 'output_batch_' . $po->output_batch_number,
|
||||
'type' => 'target_batch',
|
||||
'label' => '產出成品: ' . $po->output_batch_number,
|
||||
'batch_number' => $po->output_batch_number,
|
||||
'quantity' => $po->output_quantity,
|
||||
'product_name' => $outputInventory?->product?->name,
|
||||
'spec' => $outputInventory?->product?->spec,
|
||||
'warehouse_name' => $outputInventory?->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 追蹤成品的出庫紀錄 (銷貨、領料等)
|
||||
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
|
||||
->whereHas('inventory', function ($q) use ($po) {
|
||||
$q->where('batch_number', $po->output_batch_number);
|
||||
})
|
||||
->where('quantity', '<', 0) // 出庫
|
||||
->get();
|
||||
|
||||
foreach ($outTransactions as $txn) {
|
||||
$refType = class_basename($txn->reference_type);
|
||||
$outputNode['children'][] = [
|
||||
'id' => 'txn_' . $txn->id,
|
||||
'type' => 'outbound_transaction',
|
||||
'label' => '出庫單據: ' . $refType . ' #' . $txn->reference_id,
|
||||
'date' => $txn->actual_time,
|
||||
'quantity' => abs($txn->quantity),
|
||||
'product_name' => $txn->inventory?->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
}
|
||||
|
||||
$poNode['children'][] = $outputNode;
|
||||
}
|
||||
|
||||
$rootNode['children'][] = $poNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果這個批號自己本身就有出庫紀錄 (不是被生產掉,而是直接被領走或賣掉)
|
||||
foreach ($inventories as $inv) {
|
||||
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
|
||||
->where('inventory_id', $inv->id)
|
||||
->where('quantity', '<', 0)
|
||||
->get();
|
||||
|
||||
foreach ($outTransactions as $txn) {
|
||||
// 如果是生產工單領料,上面已經處理過,這裡濾掉
|
||||
if ($txn->reference_type && str_contains($txn->reference_type, 'ProductionOrder')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$refType = $txn->reference_type ? class_basename($txn->reference_type) : '未知';
|
||||
$rootNode['children'][] = [
|
||||
'id' => 'txn_direct_' . $txn->id,
|
||||
'type' => 'outbound_transaction',
|
||||
'label' => '直接出庫: ' . $refType . ' #' . $txn->reference_id,
|
||||
'date' => $txn->actual_time,
|
||||
'quantity' => abs($txn->quantity),
|
||||
'product_name' => $txn->inventory?->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 水和廠商名稱 (跨模組)
|
||||
*/
|
||||
private function hydrateVendorNames(array &$node): void
|
||||
{
|
||||
$vendorIds = [];
|
||||
$this->collectVendorIds($node, $vendorIds);
|
||||
|
||||
if (empty($vendorIds)) return;
|
||||
|
||||
$vendors = $this->procurementService->getVendorsByIds(array_unique($vendorIds))->keyBy('id');
|
||||
|
||||
$this->applyVendorNames($node, $vendors);
|
||||
}
|
||||
|
||||
private function collectVendorIds(array $node, array &$ids): void
|
||||
{
|
||||
if (isset($node['vendor_id'])) {
|
||||
$ids[] = $node['vendor_id'];
|
||||
}
|
||||
if (!empty($node['children'])) {
|
||||
foreach ($node['children'] as $child) {
|
||||
$this->collectVendorIds($child, $ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function applyVendorNames(array &$node, Collection $vendors): void
|
||||
{
|
||||
if (isset($node['vendor_id']) && $vendors->has($node['vendor_id'])) {
|
||||
$vendor = $vendors->get($node['vendor_id']);
|
||||
$node['label'] .= ' (廠商: ' . $vendor->name . ')';
|
||||
}
|
||||
if (!empty($node['children'])) {
|
||||
foreach ($node['children'] as &$child) {
|
||||
$this->applyVendorNames($child, $vendors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,96 +9,172 @@ use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
protected InventoryServiceInterface $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立調撥單草稿
|
||||
*/
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder
|
||||
{
|
||||
return InventoryTransferOrder::create([
|
||||
// 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定)
|
||||
if (is_null($transitWarehouseId)) {
|
||||
$fromWarehouse = Warehouse::find($fromWarehouseId);
|
||||
if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) {
|
||||
$transitWarehouseId = $fromWarehouse->default_transit_warehouse_id;
|
||||
}
|
||||
}
|
||||
|
||||
$order = new InventoryTransferOrder([
|
||||
'from_warehouse_id' => $fromWarehouseId,
|
||||
'to_warehouse_id' => $toWarehouseId,
|
||||
'transit_warehouse_id' => $transitWarehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
// 手動觸發單號產生邏輯,因為 saveQuietly 繞過了 Model Events
|
||||
if (empty($order->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'TRF-' . $today . '-';
|
||||
$lastDoc = InventoryTransferOrder::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
$order->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
|
||||
$order->saveQuietly();
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調撥單明細
|
||||
*/
|
||||
/**
|
||||
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
|
||||
*/
|
||||
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
|
||||
{
|
||||
return DB::transaction(function () use ($order, $itemsData) {
|
||||
// 1. 準備舊資料索引 (Key: product_id . '_' . batch_number)
|
||||
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
|
||||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||||
return [$key => $item];
|
||||
});
|
||||
|
||||
// 釋放舊明細的預扣庫存 (必須加鎖,防止並發更新時數量出錯)
|
||||
foreach ($order->items as $item) {
|
||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($inv) {
|
||||
$inv->releaseReservedQuantity($item->quantity);
|
||||
}
|
||||
}
|
||||
|
||||
$diff = [
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// 2. 處理新資料 (Deleted and Re-inserted currently for simplicity, but logic simulates update)
|
||||
// 為了保持 ID 當作外鍵的穩定性,最佳做法是 update 存在的,create 新的,delete 舊的。
|
||||
// 但考量現有邏輯是 delete all -> create all,我們維持原策略但優化 Diff 計算。
|
||||
|
||||
// 由於採用全刪重建,我們必須手動計算 Diff
|
||||
// 先刪除舊明細
|
||||
$order->items()->delete();
|
||||
|
||||
|
||||
$itemsToInsert = [];
|
||||
$newItemsKeys = [];
|
||||
|
||||
// 1. 批量收集待插入的明細數據
|
||||
foreach ($itemsData as $data) {
|
||||
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
||||
$newItemsKeys[] = $key;
|
||||
|
||||
$item = $order->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'transfer_order_id' => $order->id,
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'position' => $data['position'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
// Eager load product for name
|
||||
$item->load('product');
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
// 2. 執行批量寫入 (提升效能:100 筆明細只需 1 次寫入)
|
||||
if (!empty($itemsToInsert)) {
|
||||
InventoryTransferItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
// 3. 重新載入明細進行預扣處理與 Diff 計算 (因 insert 不返回 Model)
|
||||
$order->load(['items.product.baseUnit']);
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||||
|
||||
// 增加新明細的預扣庫存 (使用 lockForUpdate 確保並發安全)
|
||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$inv) {
|
||||
$inv = Inventory::create([
|
||||
'warehouse_id' => $order->from_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'quantity' => 0,
|
||||
'unit_cost' => 0,
|
||||
'total_value' => 0,
|
||||
]);
|
||||
$inv = $inv->fresh()->lockForUpdate();
|
||||
}
|
||||
|
||||
$inv->reserveQuantity($item->quantity);
|
||||
|
||||
// 計算 Diff 用於日誌
|
||||
$data = collect($itemsData)->first(fn($d) => $d['product_id'] == $item->product_id && ($d['batch_number'] ?? '') == ($item->batch_number ?? ''));
|
||||
|
||||
// 比對邏輯
|
||||
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)) {
|
||||
if ((float)$oldItem->quantity !== (float)$item->quantity ||
|
||||
$oldItem->notes !== $item->notes ||
|
||||
$oldItem->position !== $item->position) {
|
||||
|
||||
$diff['updated'][] = [
|
||||
'product_name' => $item->product->name,
|
||||
'unit_name' => $item->product->baseUnit?->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->quantity,
|
||||
'position' => $oldItem->position,
|
||||
'notes' => $oldItem->notes,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => (float)$data['quantity'],
|
||||
'quantity' => (float)$item->quantity,
|
||||
'position' => $item->position,
|
||||
'notes' => $item->notes,
|
||||
]
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 新增 (使用者需求:顯示為更新,從 0 -> X)
|
||||
$diff['updated'][] = [
|
||||
$diff['added'][] = [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
'quantity' => 0,
|
||||
'notes' => null,
|
||||
],
|
||||
'unit_name' => $item->product->baseUnit?->name,
|
||||
'new' => [
|
||||
'quantity' => (float)$item->quantity,
|
||||
'notes' => $item->notes,
|
||||
@@ -107,11 +183,11 @@ class TransferService
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 處理被移除的項目
|
||||
foreach ($oldItemsMap as $key => $oldItem) {
|
||||
if (!in_array($key, $newItemsKeys)) {
|
||||
$diff['removed'][] = [
|
||||
'product_name' => $oldItem->product->name,
|
||||
'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})",
|
||||
'unit_name' => $oldItem->product?->baseUnit?->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->quantity,
|
||||
'notes' => $oldItem->notes,
|
||||
@@ -120,7 +196,6 @@ class TransferService
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 將 Diff 注入到 Model 的暫存屬性中
|
||||
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
||||
if ($hasChanged) {
|
||||
$order->activityProperties['items_diff'] = $diff;
|
||||
@@ -131,24 +206,29 @@ class TransferService
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的)
|
||||
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
||||
*/
|
||||
public function post(InventoryTransferOrder $order, int $userId): void
|
||||
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
// [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems,導致記憶體中快取的 items 是舊的或空的
|
||||
$order->load('items.product');
|
||||
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$fromWarehouse = $order->fromWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
$hasTransit = !empty($order->transit_warehouse_id);
|
||||
|
||||
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
||||
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
||||
|
||||
$itemsDiff = [];
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 處理來源倉 (扣除)
|
||||
// 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣
|
||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||
@@ -159,103 +239,256 @@ class TransferService
|
||||
]);
|
||||
}
|
||||
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||
|
||||
// 儲存庫存快照
|
||||
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
||||
$sourceBefore = (float) $sourceInventory->quantity;
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
// 更新總值 (假設成本不變)
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
$sourceInventory->save();
|
||||
// 釋放草稿階段預扣的庫存
|
||||
$sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity);
|
||||
$sourceInventory->saveQuietly();
|
||||
|
||||
// 記錄來源交易
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '調撥出庫',
|
||||
'quantity' => -$item->quantity,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
$item->update(['snapshot_quantity' => $sourceBefore]);
|
||||
|
||||
// 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' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'total_value' => 0,
|
||||
// 繼承其他屬性
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
// 委託 InventoryService 處理扣庫與 Transaction
|
||||
$this->inventoryService->decreaseInventoryQuantity(
|
||||
$sourceInventory->id,
|
||||
$item->quantity,
|
||||
"調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
||||
InventoryTransferOrder::class,
|
||||
$order->id
|
||||
);
|
||||
|
||||
// 若是新建立的,且成本為0,確保繼承成本
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
||||
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
||||
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
||||
$targetInventory->save();
|
||||
// 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition
|
||||
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||
|
||||
// 記錄目的交易
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '調撥入庫',
|
||||
$this->inventoryService->createInventoryRecord([
|
||||
'warehouse_id' => $targetWarehouseId,
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
'reference_type' => InventoryTransferOrder::class,
|
||||
'reference_id' => $order->id,
|
||||
'location' => $hasTransit ? null : ($item->position ?? null),
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
]);
|
||||
|
||||
$targetAfter = $targetBefore + (float) $item->quantity;
|
||||
|
||||
// 記錄異動明細供整合日誌使用
|
||||
$itemsDiff[] = [
|
||||
'product_name' => $item->product->name,
|
||||
'batch_number' => $item->batch_number,
|
||||
'quantity' => (float)$item->quantity,
|
||||
'source_warehouse' => $fromWarehouse->name,
|
||||
'source_before' => $sourceBefore,
|
||||
'source_after' => $sourceAfter,
|
||||
'target_warehouse' => $targetWarehouse->name,
|
||||
'target_before' => $targetBefore,
|
||||
'target_after' => $targetAfter,
|
||||
];
|
||||
}
|
||||
|
||||
// 準備品項快照供日誌使用
|
||||
$itemsSnapshot = $order->items->map(function($item) {
|
||||
return [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$item->quantity,
|
||||
'notes' => $item->notes,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => (float)$item->quantity,
|
||||
'notes' => $item->notes,
|
||||
]
|
||||
];
|
||||
})->toArray();
|
||||
$oldStatus = $order->status;
|
||||
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->saveQuietly();
|
||||
|
||||
$order->status = 'completed';
|
||||
$order->posted_at = now();
|
||||
$order->posted_by = $userId;
|
||||
$order->save(); // 觸發自動日誌
|
||||
// 手動觸發單一合併日誌
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'items_diff' => $itemsDiff,
|
||||
'attributes' => [
|
||||
'status' => $order->status,
|
||||
'dispatched_at' => $order->dispatched_at ? $order->dispatched_at->format('Y-m-d H:i:s') : null,
|
||||
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i:s') : null,
|
||||
'dispatched_by' => $order->dispatched_by,
|
||||
'posted_by' => $order->posted_by,
|
||||
],
|
||||
'old' => [
|
||||
'status' => $oldStatus,
|
||||
]
|
||||
])
|
||||
->log($order->status == 'completed' ? 'posted' : 'dispatched');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
||||
*/
|
||||
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;
|
||||
|
||||
$itemsDiff = [];
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣
|
||||
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
||||
$availableQty = $transitInventory->quantity ?? 0;
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}。"],
|
||||
]);
|
||||
}
|
||||
|
||||
$transitBefore = (float) $transitInventory->quantity;
|
||||
|
||||
// 委託 InventoryService 處理扣庫與 Transaction
|
||||
$this->inventoryService->decreaseInventoryQuantity(
|
||||
$transitInventory->id,
|
||||
$item->quantity,
|
||||
"調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
|
||||
InventoryTransferOrder::class,
|
||||
$order->id
|
||||
);
|
||||
|
||||
$transitAfter = $transitBefore - (float) $item->quantity;
|
||||
|
||||
// 2. 目的倉增加 - 同樣需要鎖定
|
||||
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||
|
||||
$this->inventoryService->createInventoryRecord([
|
||||
'warehouse_id' => $order->to_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $transitInventory->unit_cost,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $transitInventory->expiry_date,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
|
||||
'reference_type' => InventoryTransferOrder::class,
|
||||
'reference_id' => $order->id,
|
||||
'location' => $item->position,
|
||||
'origin_country' => $transitInventory->origin_country,
|
||||
'quality_status' => $transitInventory->quality_status,
|
||||
]);
|
||||
|
||||
$targetAfter = $targetBefore + (float) $item->quantity;
|
||||
|
||||
$itemsDiff[] = [
|
||||
'product_name' => $item->product->name,
|
||||
'batch_number' => $item->batch_number,
|
||||
'quantity' => (float)$item->quantity,
|
||||
'source_warehouse' => $transitWarehouse->name,
|
||||
'source_before' => $transitBefore,
|
||||
'source_after' => $transitAfter,
|
||||
'target_warehouse' => $toWarehouse->name,
|
||||
'target_before' => $targetBefore,
|
||||
'target_after' => $targetAfter,
|
||||
];
|
||||
}
|
||||
|
||||
$oldStatus = $order->status;
|
||||
$order->status = 'completed';
|
||||
$order->posted_at = now();
|
||||
$order->posted_by = $userId;
|
||||
$order->received_at = now();
|
||||
$order->received_by = $userId;
|
||||
$order->saveQuietly();
|
||||
|
||||
// 手動觸發單一合併日誌
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'items_diff' => $itemsDiff,
|
||||
'attributes' => [
|
||||
'status' => 'completed',
|
||||
'posted_at' => $order->posted_at->format('Y-m-d H:i:s'),
|
||||
'received_at' => $order->received_at->format('Y-m-d H:i:s'),
|
||||
'posted_by' => $order->posted_by,
|
||||
'received_by' => $order->received_by,
|
||||
],
|
||||
'old' => [
|
||||
'status' => $oldStatus,
|
||||
]
|
||||
])
|
||||
->log('received');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作廢 (Void) - 僅限草稿狀態
|
||||
*/
|
||||
public function void(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$order->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
foreach ($order->items as $item) {
|
||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($inv) {
|
||||
$inv->releaseReservedQuantity($item->quantity);
|
||||
}
|
||||
}
|
||||
|
||||
$oldStatus = $order->status;
|
||||
$order->status = 'voided';
|
||||
$order->updated_by = $userId;
|
||||
$order->saveQuietly();
|
||||
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => [
|
||||
'status' => 'voided',
|
||||
],
|
||||
'old' => [
|
||||
'status' => $oldStatus,
|
||||
]
|
||||
])
|
||||
->log('voided');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
283
app/Modules/Inventory/Services/TurnoverService.php
Normal file
283
app/Modules/Inventory/Services/TurnoverService.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?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 = null)
|
||||
{
|
||||
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$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.
|
||||
|
||||
$analysisDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.analysis_period_days', 30);
|
||||
$thirtyDaysAgo = Carbon::now()->subDays($analysisDays);
|
||||
|
||||
// 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(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_type');
|
||||
})
|
||||
->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', '出庫')
|
||||
->where(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_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) * $analysisDays) / 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') {
|
||||
$deadStockDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.dead_stock_days', 90);
|
||||
$ninetyDaysAgo = Carbon::now()->subDays($deadStockDays);
|
||||
$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;
|
||||
|
||||
$deadStockDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.dead_stock_days', 90);
|
||||
$slowMovingDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.slow_moving_days', 60);
|
||||
|
||||
if ($item->current_stock > 0 && $daysSinceSale > $deadStockDays) {
|
||||
$item->status = 'dead'; // 滯銷
|
||||
$item->status_label = '滯銷';
|
||||
} elseif ($item->current_stock > 0 && $item->turnover_days > $slowMovingDays) {
|
||||
$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.
|
||||
$deadStockDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.dead_stock_days', 90);
|
||||
$ninetyDaysAgo = Carbon::now()->subDays($deadStockDays);
|
||||
|
||||
// Get IDs of products sold in last 90 days
|
||||
$soldProductIds = InventoryTransaction::query()
|
||||
->where('type', '出庫')
|
||||
->where(function ($q) {
|
||||
$q->whereIn('reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('reference_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(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_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');
|
||||
$analysisDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.analysis_period_days', 30);
|
||||
$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(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_type');
|
||||
})
|
||||
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
|
||||
->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 * $analysisDays) / $totalSales30d : 0;
|
||||
|
||||
return [
|
||||
'total_stock_value' => $totalValue,
|
||||
'dead_stock_value' => $deadStockValue,
|
||||
'dead_stock_count' => $deadStockProductCount,
|
||||
'avg_turnover_days' => round($avgTurnoverDays, 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user