Compare commits
44 Commits
58bd995cd8
...
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 |
@@ -1,115 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
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`?
|
|
||||||
@@ -62,6 +62,10 @@ trigger: always_on
|
|||||||
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
* 必須考慮 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)
|
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||||
@@ -80,8 +84,17 @@ trigger: always_on
|
|||||||
* **執行 Composer**: `./vendor/bin/sail composer install`
|
* **執行 Composer**: `./vendor/bin/sail composer install`
|
||||||
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||||
|
|
||||||
## 10. 日期處理 (Date Handling)
|
## 10. 部署與查修環境 (CI/CD & Troubleshooting)
|
||||||
|
* **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。
|
||||||
|
* **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`。
|
||||||
|
* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。
|
||||||
|
|
||||||
- 前端顯示日期時預設使用 `resources/js/lib/date.ts` 提供的 `formatDate` 工具。
|
## 11. 瀏覽器測試規範 (Browser Testing)
|
||||||
- 避免直接顯示原始 ISO 字串(如 `...Z` 結尾的格式)。
|
當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊:
|
||||||
- **智慧格式切換**:`formatDate` 會自動判斷原始資料,若時間部分為 `00:00:00` 則僅顯示 `YYYY-MM-DD`,否則顯示 `YYYY-MM-DD HH:mm:ss`。
|
|
||||||
|
* **本地測試網址**:`http://localhost:8081/`
|
||||||
|
* **預設管理員帳號**:`admin`
|
||||||
|
* **預設管理員密碼**:`password`
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port,以避免連線至錯誤的服務環境。
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
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` 進行顯示控制。
|
|
||||||
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 文件可能已經更新,且記憶中的內容可能不完整。
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
name: 客戶端後台 UI 統一規範
|
|
||||||
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
|
|
||||||
---
|
|
||||||
|
|
||||||
## 適用範圍
|
|
||||||
|
|
||||||
本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
|
|
||||||
|
|
||||||
## 核心禁止事項
|
|
||||||
|
|
||||||
- ❌ **禁止 Hardcode 色碼**(如 `text-[#01ab83]`),必須使用 `*-primary-main` 等 Tailwind Class 或 CSS 變數
|
|
||||||
- ❌ **禁止使用非 `lucide-react` 的圖標庫**(如 FontAwesome、Material Icons)
|
|
||||||
- ❌ **禁止操作按鈕不包裹 `<Can>` 權限元件**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 色彩系統
|
|
||||||
|
|
||||||
### 主題色(動態租戶品牌色,由 `AuthenticatedLayout` 自動注入)
|
|
||||||
|
|
||||||
| Tailwind Class | 用途 |
|
|
||||||
|---|---|
|
|
||||||
| `*-primary-main` | 主色:按鈕、連結、強調 |
|
|
||||||
| `*-primary-dark` | Hover 狀態 |
|
|
||||||
| `*-primary-light` | 次要強調 |
|
|
||||||
| `*-primary-lightest` | 背景底色、Active 狀態 |
|
|
||||||
|
|
||||||
### 灰階與狀態色
|
|
||||||
|
|
||||||
直接參考 `resources/css/app.css` 中定義的 `--grey-0` ~ `--grey-5` 與 `--other-success/error/warning/info` 變數。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 按鈕規範
|
|
||||||
|
|
||||||
樣式定義於 `resources/css/app.css`,按鈕必須使用以下類別:
|
|
||||||
|
|
||||||
| 類型 | 類別 | 用途 |
|
|
||||||
|---|---|---|
|
|
||||||
| Filled | `button-filled-primary` | 主要操作(新增、儲存) |
|
|
||||||
| Filled | `button-filled-success/info/warning/error` | 各狀態操作 |
|
|
||||||
| Outlined | `button-outlined-primary` | 次要操作(編輯、檢視) |
|
|
||||||
| Outlined | `button-outlined-error` | 刪除按鈕 |
|
|
||||||
| Text | `button-text-primary` | 文字連結式按鈕 |
|
|
||||||
|
|
||||||
**尺寸**:表格操作列用 `size="sm"`,一般操作用 `size="default"`,主要 CTA 用 `size="lg"`。
|
|
||||||
|
|
||||||
**返回按鈕**:放置於標題上方,使用 `variant="outline"` + `className="gap-2 button-outlined-primary"`,搭配 `<ArrowLeft />` 圖標。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 圖標規範
|
|
||||||
|
|
||||||
統一使用 `lucide-react`。
|
|
||||||
|
|
||||||
| 尺寸 | 用途 |
|
|
||||||
|---|---|
|
|
||||||
| `h-4 w-4` | 按鈕內、表格操作 |
|
|
||||||
| `h-5 w-5` | 側邊欄選單 |
|
|
||||||
| `h-6 w-6` | 頁面標題 |
|
|
||||||
|
|
||||||
常用映射:`Plus`(新增)、`Pencil`(編輯)、`Trash2`(刪除)、`Eye`(檢視)、`Search`(搜尋)、`ArrowLeft`(返回)。
|
|
||||||
其餘請參考 `AuthenticatedLayout.tsx` 中的 `allMenuItems` 定義。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 頁面佈局規範
|
|
||||||
|
|
||||||
所有頁面遵循以下結構,參考範例:`Pages/Product/Create.tsx`、`Pages/PurchaseOrder/Create.tsx`。
|
|
||||||
|
|
||||||
**關鍵規則**:
|
|
||||||
- **外層容器**:`className="container mx-auto p-6 max-w-7xl"`
|
|
||||||
- **標題樣式**:`text-2xl font-bold text-grey-0 flex items-center gap-2`
|
|
||||||
- **說明文字**:`text-gray-500 mt-1`
|
|
||||||
- **麵包屑**:使用 `BreadcrumbItemType`(屬性為 `label`, `href`, `isPage`),不需要包含「首頁」
|
|
||||||
- **Input 元件**:不額外設定 focus 樣式,直接使用 `@/Components/ui/input` 的內建樣式
|
|
||||||
- **日期顯示**:使用 `resources/js/lib/date.ts` 的 `formatDate` 工具
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 表格規範
|
|
||||||
|
|
||||||
**容器**:`bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden`
|
|
||||||
**標題列**:`bg-gray-50`,序號欄 `w-[50px] text-center`,操作欄置中
|
|
||||||
**空狀態**:`text-center py-8 text-gray-500`,顯示「無符合條件的資料」
|
|
||||||
**操作欄**:`flex items-center justify-center gap-2`
|
|
||||||
|
|
||||||
### 排序(三態切換)
|
|
||||||
|
|
||||||
- 未排序:`ArrowUpDown`(`text-muted-foreground`)
|
|
||||||
- 升冪:`ArrowUp`(`text-primary`)
|
|
||||||
- 降冪:`ArrowDown`(`text-primary`)
|
|
||||||
- 後端必須處理 `sort_by` 與 `sort_order` 參數
|
|
||||||
- 參考實作:`Pages/Product/Index.tsx` 的 `handleSort`
|
|
||||||
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` 兩處同步?
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
---
|
||||||
name: 跨模組調用與通訊規範 (Cross-Module Communication)
|
name: 跨模組調用與通訊規範 (Cross-Module Communication)
|
||||||
description: 規範 Laravel Modular Monolith 架構下,不同業務模組中如何彼此調用資料與邏輯,包含禁止項目、Interface 實作、與 Service 綁定規則。
|
description: 規範 Laravel Modular Monolith 架構下,不同業務模組中如何彼此調用資料與邏輯,包含禁止項目、Interface 實作、與 Service 綁定規則。
|
||||||
@@ -14,12 +10,8 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
|
|||||||
## 🚫 絕對禁止的行為 (Strict Prohibitions)
|
## 🚫 絕對禁止的行為 (Strict Prohibitions)
|
||||||
|
|
||||||
* **禁止跨模組 Eloquent 關聯(例外除外)**
|
* **禁止跨模組 Eloquent 關聯(例外除外)**
|
||||||
* **錯誤**:在 `app/Modules/Sales/Models/Order.php` 中撰寫 `public function product() { return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); }`。
|
|
||||||
* **原因**:這會造成資料庫查詢的強耦合。如果 `Inventory` 模組修改了 Schema,`Sales` 模組會無預警崩壞。
|
|
||||||
* **禁止跨模組直接引入 (use) Model**
|
* **禁止跨模組直接引入 (use) Model**
|
||||||
* **錯誤**:在 `app/Modules/Procurement/Controllers/PurchaseOrderController.php` 頂端寫 `use App\Modules\Inventory\Models\Warehouse;`。
|
|
||||||
* **禁止跨模組直接實例化 (new) Service**
|
* **禁止跨模組直接實例化 (new) Service**
|
||||||
* **錯誤**:`$inventoryService = new \App\Modules\Inventory\Services\InventoryService();`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,9 +20,9 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
|
|||||||
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
|
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
|
||||||
|
|
||||||
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model:
|
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model:
|
||||||
1. **`App\Modules\Core\Models\User`**:因為幾乎所有表都有 `created_by` / `updated_by`,直接關聯可保留 `with('creator')` 等便利性。
|
1. **`App\Modules\Core\Models\User`**
|
||||||
2. **`App\Modules\Core\Models\Role`**:權限判定已深度整合至系統底層。
|
2. **`App\Modules\Core\Models\Role`**
|
||||||
3. **`App\Modules\Core\Models\Tenant`**:多租戶架構 (Tenancy) 的核心基石,底層查詢會頻繁依賴。
|
3. **`App\Modules\Core\Models\Tenant`**
|
||||||
|
|
||||||
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`)**絕對不能**反過來直接 `use` 外部業務模組的 Model,仍必須透過外部模組的 Service Interface 來索取資料。
|
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`)**絕對不能**反過來直接 `use` 外部業務模組的 Model,仍必須透過外部模組的 Service Interface 來索取資料。
|
||||||
|
|
||||||
@@ -45,18 +37,12 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
|
|||||||
如果 `Inventory` 模組需要提供功能給外部使用,請在 `app/Modules/Inventory/Contracts/` 建立 Interface 檔案。
|
如果 `Inventory` 模組需要提供功能給外部使用,請在 `app/Modules/Inventory/Contracts/` 建立 Interface 檔案。
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Modules/Inventory/Contracts/InventoryServiceInterface.php
|
|
||||||
namespace App\Modules\Inventory\Contracts;
|
namespace App\Modules\Inventory\Contracts;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
interface InventoryServiceInterface
|
interface InventoryServiceInterface
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* 取得可用的倉庫清單
|
|
||||||
*
|
|
||||||
* @return Collection 包含每個倉庫的 id, name, code 等基本資料
|
|
||||||
*/
|
|
||||||
public function getActiveWarehouses(): Collection;
|
public function getActiveWarehouses(): Collection;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -66,7 +52,6 @@ interface InventoryServiceInterface
|
|||||||
由 `Inventory` 模組自己的 Service 來實作上述介面。
|
由 `Inventory` 模組自己的 Service 來實作上述介面。
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Modules/Inventory/Services/InventoryService.php
|
|
||||||
namespace App\Modules\Inventory\Services;
|
namespace App\Modules\Inventory\Services;
|
||||||
|
|
||||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
@@ -77,8 +62,6 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
{
|
{
|
||||||
public function getActiveWarehouses(): Collection
|
public function getActiveWarehouses(): Collection
|
||||||
{
|
{
|
||||||
// 建議只取出需要的欄位,或者轉換為 DTO / 陣列
|
|
||||||
// 避免將完整的 Eloquent Model 實例拋出模組外
|
|
||||||
return Warehouse::where('is_active', true)
|
return Warehouse::where('is_active', true)
|
||||||
->select(['id', 'name', 'code'])
|
->select(['id', 'name', 'code'])
|
||||||
->get();
|
->get();
|
||||||
@@ -89,7 +72,6 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
然後進入 `app/Modules/Inventory/InventoryServiceProvider.php` 完成綁定:
|
然後進入 `app/Modules/Inventory/InventoryServiceProvider.php` 完成綁定:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Modules/Inventory/InventoryServiceProvider.php
|
|
||||||
namespace App\Modules\Inventory;
|
namespace App\Modules\Inventory;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -100,7 +82,6 @@ class InventoryServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
// 綁定介面與實體
|
|
||||||
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,10 +89,9 @@ class InventoryServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
### Step 3: 調用方透過依賴注入 (DI) 使用服務
|
### Step 3: 調用方透過依賴注入 (DI) 使用服務
|
||||||
|
|
||||||
當 `Procurement` 模組需要取得倉庫資料時,禁止直接 new 服務或呼叫倉庫 Model。必須透過**建構子注入**或**方法注入**取得 `InventoryServiceInterface`。
|
當 `Procurement` 模組需要取得倉庫資料時,必須透過**建構子注入**或**方法注入**取得 `InventoryServiceInterface`。
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// app/Modules/Procurement/Controllers/PurchaseOrderController.php
|
|
||||||
namespace App\Modules\Procurement\Controllers;
|
namespace App\Modules\Procurement\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@@ -120,14 +100,12 @@ use Inertia\Inertia;
|
|||||||
|
|
||||||
class PurchaseOrderController extends Controller
|
class PurchaseOrderController extends Controller
|
||||||
{
|
{
|
||||||
// 透過建構子注入介面
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected InventoryServiceInterface $inventoryService
|
protected InventoryServiceInterface $inventoryService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
// 僅能呼叫介面有定義的方法
|
|
||||||
$warehouses = $this->inventoryService->getActiveWarehouses();
|
$warehouses = $this->inventoryService->getActiveWarehouses();
|
||||||
|
|
||||||
return Inertia::render('Procurement/PurchaseOrder/Create', [
|
return Inertia::render('Procurement/PurchaseOrder/Create', [
|
||||||
@@ -141,14 +119,11 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
## ⚠️ 跨模組資料回傳的注意事項 (Data Hydration)
|
## ⚠️ 跨模組資料回傳的注意事項 (Data Hydration)
|
||||||
|
|
||||||
* **回傳純粹資料**:為了防止其他模組意外觸發 Lazy Loading (`$item->product->name`),請盡量在 Service 中就用 `with()` 載入好關聯,或者直接轉為原生的 Array、`stdClass`、或具體的 DTO。
|
* **回傳純粹資料**:建議在 Service 中用 `with()` 載入好關聯,或者直接轉為原生的 Array 或有具體結構的 DTO,避免依賴 Lazy Loading。
|
||||||
* **手動組合 (Manual Hydration)**:若某個頁面需要合併兩個模組的資料,這也是被允許的,但必須在 Controller 層級呼叫兩個不同的 Service Interface 後,手動合併。
|
* **手動組合 (Manual Hydration)**:若某個頁面需要合併兩個模組的資料,必須在 Controller 層級呼叫兩個不同的 Service Interface 後,手動合併。
|
||||||
|
|
||||||
### 範例:手動合併資料
|
### 範例:手動合併資料
|
||||||
```php
|
```php
|
||||||
// 錯誤示範(禁止在 OrderService 中去查使用者的關聯)
|
|
||||||
$orders = Order::with('user')->get(); // 如果 user 表在 Core 模組,這是不允許的
|
|
||||||
|
|
||||||
// 正確示範:在各自模組取資料,並手動組裝
|
// 正確示範:在各自模組取資料,並手動組裝
|
||||||
$orders = $this->orderService->getOrders();
|
$orders = $this->orderService->getOrders();
|
||||||
$userIds = $orders->pluck('user_id')->unique()->toArray();
|
$userIds = $orders->pluck('user_id')->unique()->toArray();
|
||||||
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 style={{ borderColor: 'var(--primary-main)' }}>...</div>
|
||||||
|
|
||||||
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
|
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
|
||||||
<div className="text-[#01ab83]">...</div>
|
<div className="text-primary-main">...</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 灰階 (Grey Scale)
|
### 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">
|
<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>
|
</h1>
|
||||||
|
|
||||||
@@ -584,7 +584,7 @@ export default function ResourceIndex() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
<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>
|
</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">
|
||||||
@@ -781,7 +781,75 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
|||||||
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
||||||
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `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>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11.7 搜尋選單樣式 (SearchableSelect Style)
|
## 11.8 搜尋選單樣式 (SearchableSelect Style)
|
||||||
|
|
||||||
`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。
|
`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)應遵循以下規範以節省空間並保持層級清晰:
|
列表頁面的篩選區域(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` 分支,維持全環境版本一致。
|
||||||
@@ -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` 進行顯示控制。
|
|
||||||
@@ -92,6 +92,7 @@ jobs:
|
|||||||
php artisan tenants:migrate --force &&
|
php artisan tenants:migrate --force &&
|
||||||
php artisan db:seed --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=PermissionSeeder\" --option=\"force=true\" &&
|
||||||
|
php artisan tenants:run db:seed --option=\"class=SystemSettingSeeder\" --option=\"force=true\" &&
|
||||||
php artisan permission:cache-reset &&
|
php artisan permission:cache-reset &&
|
||||||
php artisan optimize:clear &&
|
php artisan optimize:clear &&
|
||||||
php artisan optimize &&
|
php artisan optimize &&
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ jobs:
|
|||||||
php artisan tenants:migrate --force &&
|
php artisan tenants:migrate --force &&
|
||||||
php artisan db:seed --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=PermissionSeeder\" --option=\"force=true\" &&
|
||||||
|
php artisan tenants:run db:seed --option=\"class=SystemSettingSeeder\" --option=\"force=true\" &&
|
||||||
php artisan permission:cache-reset &&
|
php artisan permission:cache-reset &&
|
||||||
php artisan optimize:clear &&
|
php artisan optimize:clear &&
|
||||||
php artisan optimize &&
|
php artisan optimize &&
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -18,6 +18,7 @@
|
|||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/storage/tenant*
|
||||||
/vendor
|
/vendor
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
@@ -29,3 +30,16 @@ Thumbs.db
|
|||||||
/docs/presentation
|
/docs/presentation
|
||||||
docs/Monthly_Report_2026_01.pptx
|
docs/Monthly_Report_2026_01.pptx
|
||||||
docs/f6_1770350984272.xlsx
|
docs/f6_1770350984272.xlsx
|
||||||
|
公共事業費-描述.md
|
||||||
|
.gitignore
|
||||||
|
BOM表自動計算成本.md
|
||||||
|
公共事業費-類別維護.md
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
e2e/screenshots/
|
||||||
|
|||||||
@@ -180,4 +180,3 @@ docker compose down
|
|||||||
- **多租戶**:
|
- **多租戶**:
|
||||||
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
||||||
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
- 租戶路由定義於 `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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ class RoleController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function getGroupedPermissions()
|
private function getGroupedPermissions()
|
||||||
{
|
{
|
||||||
$allPermissions = Permission::orderBy('name')->get();
|
$allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get();
|
||||||
$grouped = [];
|
$grouped = [];
|
||||||
|
|
||||||
foreach ($allPermissions as $permission) {
|
foreach ($allPermissions as $permission) {
|
||||||
|
|||||||
@@ -43,4 +43,55 @@ class SystemSettingController extends Controller
|
|||||||
|
|
||||||
return redirect()->back()->with('success', '系統設定已更新');
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::middleware('permission:system.settings.view')->group(function () {
|
Route::middleware('permission:system.settings.view')->group(function () {
|
||||||
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
|
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
|
||||||
Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update');
|
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
|
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
|
public function getAllUsers(): Collection
|
||||||
{
|
{
|
||||||
return User::all();
|
return User::select('id', 'name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ensureSystemUserExists()
|
public function ensureSystemUserExists()
|
||||||
|
|||||||
@@ -69,14 +69,25 @@ class AccountingReportController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$exportData = $allRecords->map(function ($record) {
|
$exportData = $allRecords->map(function ($record) {
|
||||||
|
$taxAmount = (float)($record['tax_amount'] ?? 0);
|
||||||
|
$totalAmount = (float)($record['amount'] ?? 0);
|
||||||
|
$untaxedAmount = $totalAmount - $taxAmount;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
$record['date'],
|
$record['date'],
|
||||||
$record['source'],
|
$record['source'],
|
||||||
$record['category'],
|
$record['category'],
|
||||||
$record['item'],
|
$record['item'],
|
||||||
$record['reference'],
|
$record['reference'],
|
||||||
$record['invoice_number'],
|
$record['invoice_date'] ?? '-',
|
||||||
$record['amount'],
|
$record['invoice_number'] ?? '-',
|
||||||
|
$untaxedAmount,
|
||||||
|
$taxAmount,
|
||||||
|
$totalAmount,
|
||||||
|
$record['payment_method'] ?? '-',
|
||||||
|
$record['payment_note'] ?? '-',
|
||||||
|
$record['remarks'] ?? '-',
|
||||||
|
$record['status'] ?? '-',
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +102,11 @@ class AccountingReportController extends Controller
|
|||||||
// BOM for Excel compatibility with UTF-8
|
// BOM for Excel compatibility with UTF-8
|
||||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||||
|
|
||||||
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
|
fputcsv($file, [
|
||||||
|
'日期', '來源', '類別', '項目', '參考單號',
|
||||||
|
'發票日期', '發票號碼', '未稅金額', '稅額', '總金額',
|
||||||
|
'付款方式', '付款備註', '內部備註', '狀態'
|
||||||
|
]);
|
||||||
|
|
||||||
foreach ($exportData as $row) {
|
foreach ($exportData as $row) {
|
||||||
fputcsv($file, $row);
|
fputcsv($file, $row);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Finance\Models\UtilityFee;
|
use App\Modules\Finance\Models\UtilityFee;
|
||||||
|
use App\Modules\Finance\Models\UtilityFeeAttachment;
|
||||||
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class UtilityFeeController extends Controller
|
class UtilityFeeController extends Controller
|
||||||
@@ -34,13 +36,16 @@ class UtilityFeeController extends Controller
|
|||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'transaction_date' => 'required|date',
|
'transaction_date' => 'nullable|date',
|
||||||
|
'due_date' => 'required|date',
|
||||||
'category' => 'required|string|max:255',
|
'category' => 'required|string|max:255',
|
||||||
'amount' => 'required|numeric|min:0',
|
'amount' => 'required|numeric|min:0',
|
||||||
'invoice_number' => 'nullable|string|max:255',
|
'invoice_number' => 'nullable|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$validated['payment_status'] = $this->determineStatus($validated);
|
||||||
|
|
||||||
$fee = UtilityFee::create($validated);
|
$fee = UtilityFee::create($validated);
|
||||||
|
|
||||||
activity()
|
activity()
|
||||||
@@ -55,13 +60,16 @@ class UtilityFeeController extends Controller
|
|||||||
public function update(Request $request, UtilityFee $utility_fee)
|
public function update(Request $request, UtilityFee $utility_fee)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'transaction_date' => 'required|date',
|
'transaction_date' => 'nullable|date',
|
||||||
|
'due_date' => 'required|date',
|
||||||
'category' => 'required|string|max:255',
|
'category' => 'required|string|max:255',
|
||||||
'amount' => 'required|numeric|min:0',
|
'amount' => 'required|numeric|min:0',
|
||||||
'invoice_number' => 'nullable|string|max:255',
|
'invoice_number' => 'nullable|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$validated['payment_status'] = $this->determineStatus($validated);
|
||||||
|
|
||||||
$utility_fee->update($validated);
|
$utility_fee->update($validated);
|
||||||
|
|
||||||
activity()
|
activity()
|
||||||
@@ -73,6 +81,22 @@ class UtilityFeeController extends Controller
|
|||||||
return redirect()->back();
|
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)
|
public function destroy(UtilityFee $utility_fee)
|
||||||
{
|
{
|
||||||
activity()
|
activity()
|
||||||
@@ -81,8 +105,82 @@ class UtilityFeeController extends Controller
|
|||||||
->event('deleted')
|
->event('deleted')
|
||||||
->log('deleted');
|
->log('deleted');
|
||||||
|
|
||||||
|
// 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理)
|
||||||
|
foreach ($utility_fee->attachments as $attachment) {
|
||||||
|
Storage::disk('public')->delete($attachment->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
$utility_fee->delete();
|
$utility_fee->delete();
|
||||||
|
|
||||||
return redirect()->back();
|
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' => '刪除成功']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,29 +7,47 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
|
|
||||||
class UtilityFee extends Model
|
class UtilityFee extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
|
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 此公共事業費的附件
|
||||||
|
*/
|
||||||
|
public function attachments()
|
||||||
|
{
|
||||||
|
return $this->hasMany(UtilityFeeAttachment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狀態常數
|
||||||
|
const STATUS_PENDING = 'pending';
|
||||||
|
const STATUS_PAID = 'paid';
|
||||||
|
const STATUS_OVERDUE = 'overdue';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'transaction_date',
|
'transaction_date',
|
||||||
|
'due_date',
|
||||||
'category',
|
'category',
|
||||||
'amount',
|
'amount',
|
||||||
|
'payment_status',
|
||||||
'invoice_number',
|
'invoice_number',
|
||||||
'description',
|
'description',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'transaction_date' => 'date',
|
'transaction_date' => 'date:Y-m-d',
|
||||||
|
'due_date' => 'date:Y-m-d',
|
||||||
'amount' => 'decimal:2',
|
'amount' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
{
|
{
|
||||||
$activity->properties = $activity->properties->put('snapshot', [
|
$snapshot = [
|
||||||
'transaction_date' => $this->transaction_date->format('Y-m-d'),
|
'transaction_date' => $this->transaction_date?->format('Y-m-d'),
|
||||||
|
'due_date' => $this->due_date?->format('Y-m-d'),
|
||||||
'category' => $this->category,
|
'category' => $this->category,
|
||||||
'amount' => $this->amount,
|
'amount' => $this->amount,
|
||||||
|
'payment_status' => $this->payment_status,
|
||||||
'invoice_number' => $this->invoice_number,
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ Route::middleware('auth')->group(function () {
|
|||||||
});
|
});
|
||||||
Route::middleware('permission:utility_fees.edit')->group(function () {
|
Route::middleware('permission:utility_fees.edit')->group(function () {
|
||||||
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update');
|
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::middleware('permission:utility_fees.delete')->group(function () {
|
||||||
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
|
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class AccountPayableService
|
|||||||
|
|
||||||
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
||||||
->orderBy('document_number', 'desc')
|
->orderBy('document_number', 'desc')
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$latest) {
|
if (!$latest) {
|
||||||
|
|||||||
@@ -19,23 +19,48 @@ class FinanceService implements FinanceServiceInterface
|
|||||||
|
|
||||||
public function getAccountingReportData(string $start, string $end): array
|
public function getAccountingReportData(string $start, string $end): array
|
||||||
{
|
{
|
||||||
// 1. 獲取採購單資料
|
// 1. 獲取應付帳款資料 (已付款)
|
||||||
$purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end)
|
$accountPayables = \App\Modules\Finance\Models\AccountPayable::where('status', \App\Modules\Finance\Models\AccountPayable::STATUS_PAID)
|
||||||
->map(function ($po) {
|
->whereNotNull('paid_at')
|
||||||
|
->whereBetween('paid_at', [$start, $end])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 取得供應商資料 (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 [
|
return [
|
||||||
'id' => 'PO-' . $po->id,
|
'id' => 'AP-' . $ap->id,
|
||||||
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
|
'date' => Carbon::parse($ap->paid_at)->timezone(config('app.timezone'))->toDateString(),
|
||||||
'source' => '採購單',
|
'source' => '應付帳款',
|
||||||
'category' => '進貨支出',
|
'category' => '進貨支出',
|
||||||
'item' => $po->vendor->name ?? '未知廠商',
|
'item' => $vendorName,
|
||||||
'reference' => $po->code,
|
'reference' => $ap->document_number,
|
||||||
'invoice_number' => $po->invoice_number,
|
'invoice_date' => $ap->invoice_date ? $ap->invoice_date->format('Y-m-d') : null,
|
||||||
'amount' => (float)$po->grand_total,
|
'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. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date)
|
// 2. 獲取公共事業費 (已繳費)
|
||||||
$utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end])
|
$utilityFees = UtilityFee::where('payment_status', UtilityFee::STATUS_PAID)
|
||||||
|
->whereBetween('transaction_date', [$start, $end])
|
||||||
->get()
|
->get()
|
||||||
->map(function ($fee) {
|
->map(function ($fee) {
|
||||||
return [
|
return [
|
||||||
@@ -45,12 +70,18 @@ class FinanceService implements FinanceServiceInterface
|
|||||||
'category' => $fee->category,
|
'category' => $fee->category,
|
||||||
'item' => $fee->description ?: $fee->category,
|
'item' => $fee->description ?: $fee->category,
|
||||||
'reference' => '-',
|
'reference' => '-',
|
||||||
|
'invoice_date' => null,
|
||||||
'invoice_number' => $fee->invoice_number,
|
'invoice_number' => $fee->invoice_number,
|
||||||
'amount' => (float)$fee->amount,
|
'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')
|
->sortByDesc('date')
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
@@ -58,7 +89,7 @@ class FinanceService implements FinanceServiceInterface
|
|||||||
'records' => $allRecords,
|
'records' => $allRecords,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'total_amount' => $allRecords->sum('amount'),
|
'total_amount' => $allRecords->sum('amount'),
|
||||||
'purchase_total' => $purchaseOrders->sum('amount'),
|
'payable_total' => $payableRecords->sum('amount'),
|
||||||
'utility_total' => $utilityFees->sum('amount'),
|
'utility_total' => $utilityFees->sum('amount'),
|
||||||
'record_count' => $allRecords->count(),
|
'record_count' => $allRecords->count(),
|
||||||
]
|
]
|
||||||
@@ -67,7 +98,7 @@ class FinanceService implements FinanceServiceInterface
|
|||||||
|
|
||||||
public function getUtilityFees(array $filters)
|
public function getUtilityFees(array $filters)
|
||||||
{
|
{
|
||||||
$query = UtilityFee::query();
|
$query = UtilityFee::withCount('attachments');
|
||||||
|
|
||||||
if (!empty($filters['search'])) {
|
if (!empty($filters['search'])) {
|
||||||
$search = $filters['search'];
|
$search = $filters['search'];
|
||||||
|
|||||||
@@ -93,14 +93,16 @@ class SyncOrderAction
|
|||||||
'source_label' => $data['source_label'] ?? null,
|
'source_label' => $data['source_label'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 查找或建立倉庫
|
// 2. 查找倉庫
|
||||||
$warehouseId = $data['warehouse_id'] ?? null;
|
$warehouseCode = $data['warehouse_code'];
|
||||||
|
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||||
|
|
||||||
if (empty($warehouseId)) {
|
if ($warehouses->isEmpty()) {
|
||||||
$warehouseName = $data['warehouse'] ?? '銷售倉庫';
|
throw ValidationException::withMessages([
|
||||||
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
|
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||||
$warehouseId = $warehouse->id;
|
]);
|
||||||
}
|
}
|
||||||
|
$warehouseId = $warehouses->first()->id;
|
||||||
|
|
||||||
$totalAmount = 0;
|
$totalAmount = 0;
|
||||||
|
|
||||||
@@ -131,7 +133,10 @@ class SyncOrderAction
|
|||||||
$warehouseId,
|
$warehouseId,
|
||||||
$qty,
|
$qty,
|
||||||
"POS Order: " . $order->external_order_id,
|
"POS Order: " . $order->external_order_id,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
$order->id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,14 +90,16 @@ class SyncVendingOrderAction
|
|||||||
'source_label' => $data['machine_id'] ?? null,
|
'source_label' => $data['machine_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 查找或建立倉庫
|
// 2. 查找倉庫
|
||||||
$warehouseId = $data['warehouse_id'] ?? null;
|
$warehouseCode = $data['warehouse_code'];
|
||||||
|
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||||
|
|
||||||
if (empty($warehouseId)) {
|
if ($warehouses->isEmpty()) {
|
||||||
$warehouseName = $data['warehouse'] ?? '販賣機倉庫';
|
throw ValidationException::withMessages([
|
||||||
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
|
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||||
$warehouseId = $warehouse->id;
|
]);
|
||||||
}
|
}
|
||||||
|
$warehouseId = $warehouses->first()->id;
|
||||||
|
|
||||||
$totalAmount = 0;
|
$totalAmount = 0;
|
||||||
|
|
||||||
@@ -128,7 +130,10 @@ class SyncVendingOrderAction
|
|||||||
$warehouseId,
|
$warehouseId,
|
||||||
$qty,
|
$qty,
|
||||||
"Vending Order: " . $order->external_order_id,
|
"Vending Order: " . $order->external_order_id,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
$order->id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ class SyncOrderRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'external_order_id' => 'required|string',
|
'external_order_id' => 'required|string',
|
||||||
'warehouse' => 'nullable|string',
|
'warehouse_code' => 'required|string',
|
||||||
'warehouse_id' => 'nullable|integer',
|
|
||||||
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
||||||
'sold_at' => 'nullable|date',
|
'sold_at' => 'nullable|date',
|
||||||
'items' => 'required|array|min:1',
|
'items' => 'required|array|min:1',
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ class SyncVendingOrderRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'external_order_id' => 'required|string',
|
'external_order_id' => 'required|string',
|
||||||
'machine_id' => 'nullable|string',
|
'machine_id' => 'nullable|string',
|
||||||
'warehouse' => 'nullable|string',
|
'warehouse_code' => 'required|string',
|
||||||
'warehouse_id' => 'nullable|integer',
|
|
||||||
'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other',
|
'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other',
|
||||||
'sold_at' => 'nullable|date',
|
'sold_at' => 'nullable|date',
|
||||||
'items' => 'required|array|min:1',
|
'items' => 'required|array|min:1',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface InventoryServiceInterface
|
|||||||
* @param string|null $slot
|
* @param string|null $slot
|
||||||
* @return void
|
* @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.
|
* Get all active warehouses.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class AdjustDocController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Inventory/Adjust/Index', [
|
return Inertia::render('Inventory/Adjust/Index', [
|
||||||
'docs' => $docs,
|
'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']),
|
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class CountDocController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Inventory/Count/Index', [
|
return Inertia::render('Inventory/Count/Index', [
|
||||||
'docs' => $docs,
|
'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']),
|
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ class GoodsReceiptController extends Controller
|
|||||||
'id' => $po->id,
|
'id' => $po->id,
|
||||||
'code' => $po->code,
|
'code' => $po->code,
|
||||||
'status' => $po->status,
|
'status' => $po->status,
|
||||||
|
'supplierId' => $po->vendor_id, // Alias for frontend
|
||||||
'vendor_id' => $po->vendor_id,
|
'vendor_id' => $po->vendor_id,
|
||||||
'vendor_name' => $po->vendor?->name ?? '',
|
'vendor_name' => $po->vendor?->name ?? '',
|
||||||
'warehouse_id' => $po->warehouse_id,
|
'warehouse_id' => $po->warehouse_id,
|
||||||
@@ -140,15 +141,35 @@ class GoodsReceiptController extends Controller
|
|||||||
'items' => $po->items->map(function ($item) use ($products) {
|
'items' => $po->items->map(function ($item) use ($products) {
|
||||||
$product = $products->get($item->product_id);
|
$product = $products->get($item->product_id);
|
||||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
$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 [
|
return [
|
||||||
'id' => $item->id,
|
'id' => $item->id,
|
||||||
|
'productId' => $item->product_id, // Alias for frontend
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
|
'productName' => $product?->name ?? '', // Alias for frontend
|
||||||
'product_name' => $product?->name ?? '',
|
'product_name' => $product?->name ?? '',
|
||||||
'product_code' => $product?->code ?? '',
|
'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,
|
'quantity' => $item->quantity,
|
||||||
'received_quantity' => $item->received_quantity ?? 0,
|
'received_quantity' => $item->received_quantity ?? 0,
|
||||||
'remaining' => $remaining,
|
'remaining' => $remaining,
|
||||||
|
'unitPrice' => $item->unit_price, // Alias for frontend
|
||||||
'unit_price' => $item->unit_price,
|
'unit_price' => $item->unit_price,
|
||||||
];
|
];
|
||||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||||
@@ -167,7 +188,190 @@ class GoodsReceiptController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request)
|
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',
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
'type' => 'required|in:standard,miscellaneous,other',
|
'type' => 'required|in:standard,miscellaneous,other',
|
||||||
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
|
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
|
||||||
@@ -178,18 +382,12 @@ class GoodsReceiptController extends Controller
|
|||||||
'items.*.product_id' => 'required|integer|exists:products,id',
|
'items.*.product_id' => 'required|integer|exists:products,id',
|
||||||
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
|
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
|
||||||
'items.*.quantity_received' => 'required|numeric|min:0',
|
'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.*.batch_number' => 'nullable|string',
|
||||||
'items.*.expiry_date' => 'nullable|date',
|
'items.*.expiry_date' => 'nullable|date',
|
||||||
'force' => 'nullable|boolean',
|
'force' => 'nullable|boolean',
|
||||||
]);
|
];
|
||||||
|
|
||||||
try {
|
|
||||||
$this->goodsReceiptService->store($request->all());
|
|
||||||
return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立');
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return back()->with('error', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +427,7 @@ class GoodsReceiptController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// API to search Products for Manual Entry
|
// API to search Products for Manual Entry
|
||||||
|
// 支援 query='*' 回傳所有商品(用於 SearchableSelect 下拉選單)
|
||||||
public function searchProducts(Request $request)
|
public function searchProducts(Request $request)
|
||||||
{
|
{
|
||||||
$search = $request->input('query');
|
$search = $request->input('query');
|
||||||
@@ -236,7 +435,12 @@ class GoodsReceiptController extends Controller
|
|||||||
return response()->json([]);
|
return response()->json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 萬用字元:回傳所有商品
|
||||||
|
if ($search === '*') {
|
||||||
|
$products = $this->inventoryService->getProductsByName('');
|
||||||
|
} else {
|
||||||
$products = $this->inventoryService->getProductsByName($search);
|
$products = $this->inventoryService->getProductsByName($search);
|
||||||
|
}
|
||||||
|
|
||||||
// Format for frontend
|
// Format for frontend
|
||||||
$mapped = $products->map(function($product) {
|
$mapped = $products->map(function($product) {
|
||||||
@@ -244,8 +448,8 @@ class GoodsReceiptController extends Controller
|
|||||||
'id' => $product->id,
|
'id' => $product->id,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'code' => $product->code,
|
'code' => $product->code,
|
||||||
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
|
'unit' => $product->baseUnit?->name ?? '個',
|
||||||
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
|
'price' => $product->purchase_price ?? 0,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class InventoryController extends Controller
|
|||||||
'inventories.lastIncomingTransaction',
|
'inventories.lastIncomingTransaction',
|
||||||
'inventories.lastOutgoingTransaction'
|
'inventories.lastOutgoingTransaction'
|
||||||
]);
|
]);
|
||||||
$allProducts = Product::with('category')->get();
|
$allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
|
||||||
|
|
||||||
// 1. 準備 availableProducts
|
// 1. 準備 availableProducts
|
||||||
$availableProducts = $allProducts->map(function ($product) {
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
@@ -57,9 +57,31 @@ class InventoryController extends Controller
|
|||||||
->pluck('safety_stock', 'product_id')
|
->pluck('safety_stock', 'product_id')
|
||||||
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
||||||
|
|
||||||
$items = $warehouse->inventories()
|
$query = $warehouse->inventories()
|
||||||
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction'])
|
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction']);
|
||||||
->get();
|
|
||||||
|
// 加入搜尋過濾
|
||||||
|
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';
|
$isVending = $warehouse->type === 'vending';
|
||||||
@@ -145,8 +167,8 @@ class InventoryController extends Controller
|
|||||||
public function create(Warehouse $warehouse)
|
public function create(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
// ... (unchanged) ...
|
// ... (unchanged) ...
|
||||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
$products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||||
->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||||
->get()
|
->get()
|
||||||
->map(function ($product) {
|
->map(function ($product) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -112,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', [
|
return Inertia::render('Product/Index', [
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
'categories' => $categories->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]),
|
'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']),
|
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -172,8 +172,8 @@ class ProductController extends Controller
|
|||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Product/Create', [
|
return Inertia::render('Product/Create', [
|
||||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
'categories' => Category::select('id', 'name')->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]),
|
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,8 +231,8 @@ class ProductController extends Controller
|
|||||||
'wholesale_price' => (float) $product->wholesale_price,
|
'wholesale_price' => (float) $product->wholesale_price,
|
||||||
'is_active' => (bool) $product->is_active,
|
'is_active' => (bool) $product->is_active,
|
||||||
],
|
],
|
||||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
'categories' => Category::select('id', 'name')->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]),
|
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class SafetyStockController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Warehouse $warehouse)
|
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) {
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class TransferOrderController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Inventory/Transfer/Index', [
|
return Inertia::render('Inventory/Transfer/Index', [
|
||||||
'orders' => $orders,
|
'orders' => $orders,
|
||||||
'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(['search', 'warehouse_id', 'per_page']),
|
'filters' => $request->only(['search', 'warehouse_id', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ class StockQueryExport implements FromCollection, WithHeadings, WithMapping, Sho
|
|||||||
$search = $this->filters['search'];
|
$search = $this->filters['search'];
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('products.code', 'like', "%{$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'])) {
|
if (!empty($this->filters['status'])) {
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
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/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}', [\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'])
|
Route::post('/goods-receipts/check-duplicate', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'checkDuplicate'])
|
||||||
->middleware('permission:goods_receipts.create')
|
->middleware('permission:goods_receipts.create')
|
||||||
->name('goods-receipts.check-duplicate');
|
->name('goods-receipts.check-duplicate');
|
||||||
|
|||||||
@@ -44,16 +44,23 @@ class AdjustService
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($countDoc->items as $item) {
|
foreach ($countDoc->items as $item) {
|
||||||
if (abs($item->diff_qty) < 0.0001) continue;
|
if (abs($item->diff_qty) < 0.0001) continue;
|
||||||
|
|
||||||
$adjDoc->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'adjust_doc_id' => $adjDoc->id,
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
'qty_before' => $item->system_qty,
|
'qty_before' => $item->system_qty,
|
||||||
'adjust_qty' => $item->diff_qty,
|
'adjust_qty' => $item->diff_qty,
|
||||||
'notes' => "盤點差異: " . $item->diff_qty,
|
'notes' => "盤點差異: " . $item->diff_qty,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
InventoryAdjustItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $adjDoc;
|
return $adjDoc;
|
||||||
@@ -84,25 +91,35 @@ class AdjustService
|
|||||||
|
|
||||||
$doc->items()->delete();
|
$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) {
|
foreach ($itemsData as $data) {
|
||||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
$inventory = $inventories->where('product_id', $data['product_id'])
|
||||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
|
||||||
->where('product_id', $data['product_id'])
|
|
||||||
->where('batch_number', $data['batch_number'] ?? null)
|
->where('batch_number', $data['batch_number'] ?? null)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||||
|
|
||||||
$newItem = $doc->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'adjust_doc_id' => $doc->id,
|
||||||
'product_id' => $data['product_id'],
|
'product_id' => $data['product_id'],
|
||||||
'batch_number' => $data['batch_number'] ?? null,
|
'batch_number' => $data['batch_number'] ?? null,
|
||||||
'qty_before' => $qtyBefore,
|
'qty_before' => $qtyBefore,
|
||||||
'adjust_qty' => $data['adjust_qty'],
|
'adjust_qty' => $data['adjust_qty'],
|
||||||
'notes' => $data['notes'] ?? null,
|
'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;
|
$found = false;
|
||||||
foreach ($updatedItems as $idx => $ui) {
|
foreach ($updatedItems as $idx => $ui) {
|
||||||
if ($ui['product_name'] === $productName && $ui['new'] === null) {
|
if ($ui['product_name'] === $productName && $ui['new'] === null) {
|
||||||
@@ -126,6 +143,10 @@ class AdjustService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
InventoryAdjustItem::insert($itemsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理沒被更新到的舊品項 (即真正被刪除的)
|
// 清理沒被更新到的舊品項 (即真正被刪除的)
|
||||||
$finalUpdatedItems = [];
|
$finalUpdatedItems = [];
|
||||||
foreach ($updatedItems as $ui) {
|
foreach ($updatedItems as $ui) {
|
||||||
@@ -162,11 +183,20 @@ class AdjustService
|
|||||||
foreach ($doc->items as $item) {
|
foreach ($doc->items as $item) {
|
||||||
if ($item->adjust_qty == 0) continue;
|
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();
|
||||||
|
|
||||||
|
if (!$inventory) {
|
||||||
|
$inventory = new Inventory([
|
||||||
'warehouse_id' => $doc->warehouse_id,
|
'warehouse_id' => $doc->warehouse_id,
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
|
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
|
||||||
if (!$inventory->exists) {
|
if (!$inventory->exists) {
|
||||||
|
|||||||
@@ -47,18 +47,25 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
|
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
|
||||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
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'],
|
'product_id' => $itemData['product_id'],
|
||||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||||
'quantity_received' => $itemData['quantity_received'],
|
'quantity_received' => $itemData['quantity_received'],
|
||||||
'unit_price' => $itemData['unit_price'],
|
'unit_price' => $itemData['unit_price'],
|
||||||
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
|
'total_amount' => $totalAmount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$goodsReceipt->items()->save($grItem);
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
$product = $products->get($itemData['product_id']);
|
$product = $products->get($itemData['product_id']);
|
||||||
$diff['added'][] = [
|
$diff['added'][] = [
|
||||||
@@ -66,11 +73,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
'new' => [
|
'new' => [
|
||||||
'quantity_received' => (float)$itemData['quantity_received'],
|
'quantity_received' => (float)$itemData['quantity_received'],
|
||||||
'unit_price' => (float)$itemData['unit_price'],
|
'unit_price' => (float)$itemData['unit_price'],
|
||||||
'total_amount' => (float)($itemData['quantity_received'] * $itemData['unit_price']),
|
'total_amount' => (float)$totalAmount,
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
GoodsReceiptItem::insert($itemsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 手動發送高品質日誌(包含品項明細)
|
// 4. 手動發送高品質日誌(包含品項明細)
|
||||||
activity()
|
activity()
|
||||||
->performedOn($goodsReceipt)
|
->performedOn($goodsReceipt)
|
||||||
@@ -141,17 +152,29 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
if (isset($data['items'])) {
|
if (isset($data['items'])) {
|
||||||
$goodsReceipt->items()->delete();
|
$goodsReceipt->items()->delete();
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
$grItem = new GoodsReceiptItem([
|
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||||
|
$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'],
|
'product_id' => $itemData['product_id'],
|
||||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||||
'quantity_received' => $itemData['quantity_received'],
|
'quantity_received' => $itemData['quantity_received'],
|
||||||
'unit_price' => $itemData['unit_price'],
|
'unit_price' => $itemData['unit_price'],
|
||||||
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
|
'total_amount' => $totalAmount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$goodsReceipt->items()->save($grItem);
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
GoodsReceiptItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,11 +261,14 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
*/
|
*/
|
||||||
public function submit(GoodsReceipt $goodsReceipt)
|
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])) {
|
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
||||||
throw new \Exception('只有草稿或被退回的進貨單可以確認點收。');
|
throw new \Exception('只有草稿或被退回的進貨單可以確認點收。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($goodsReceipt) {
|
|
||||||
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
|
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
|
||||||
$goodsReceipt->save();
|
$goodsReceipt->save();
|
||||||
|
|
||||||
@@ -256,10 +282,24 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
default => '進貨入庫',
|
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([
|
$this->inventoryService->createInventoryRecord([
|
||||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||||
'product_id' => $grItem->product_id,
|
'product_id' => $grItem->product_id,
|
||||||
'quantity' => $grItem->quantity_received,
|
'quantity' => $quantityToRecord,
|
||||||
'unit_cost' => $grItem->unit_price,
|
'unit_cost' => $grItem->unit_price,
|
||||||
'batch_number' => $grItem->batch_number,
|
'batch_number' => $grItem->batch_number,
|
||||||
'expiry_date' => $grItem->expiry_date,
|
'expiry_date' => $grItem->expiry_date,
|
||||||
@@ -272,6 +312,7 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
|
|
||||||
// 2. 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) {
|
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) {
|
||||||
|
// 更新採購單的實收數量 (維持原始單位數量,以便與採購數量比較)
|
||||||
$this->procurementService->updateReceivedQuantity(
|
$this->procurementService->updateReceivedQuantity(
|
||||||
$grItem->purchase_order_item_id,
|
$grItem->purchase_order_item_id,
|
||||||
$grItem->quantity_received
|
$grItem->quantity_received
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
{
|
{
|
||||||
public function getAllWarehouses()
|
public function getAllWarehouses()
|
||||||
{
|
{
|
||||||
return Warehouse::all();
|
return Warehouse::select('id', 'name', 'code', 'type')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
|
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
|
||||||
@@ -38,12 +38,14 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
|
|
||||||
public function getAllProducts()
|
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()
|
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 = [])
|
public function getInventoriesByIds(array $ids, array $with = [])
|
||||||
@@ -85,9 +87,9 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
return $stock >= $quantity;
|
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)
|
$query = Inventory::where('product_id', $productId)
|
||||||
->where('warehouse_id', $warehouseId)
|
->where('warehouse_id', $warehouseId)
|
||||||
->where('quantity', '>', 0);
|
->where('quantity', '>', 0);
|
||||||
@@ -96,7 +98,8 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
$query->where('location', $slot);
|
$query->where('location', $slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
$inventories = $query->orderBy('arrival_date', 'asc')
|
$inventories = $query->lockForUpdate()
|
||||||
|
->orderBy('arrival_date', 'asc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$remainingToDecrease = $quantity;
|
$remainingToDecrease = $quantity;
|
||||||
@@ -105,7 +108,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
if ($remainingToDecrease <= 0) break;
|
if ($remainingToDecrease <= 0) break;
|
||||||
|
|
||||||
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
||||||
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason);
|
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
|
||||||
$remainingToDecrease -= $decreaseAmount;
|
$remainingToDecrease -= $decreaseAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason);
|
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId);
|
||||||
} else {
|
} else {
|
||||||
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
||||||
}
|
}
|
||||||
@@ -303,12 +306,14 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
$query->where('products.category_id', $filters['category_id']);
|
$query->where('products.category_id', $filters['category_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 篩選:關鍵字(商品代碼或名稱)
|
// 篩選:關鍵字(商品代碼或名稱或批號)
|
||||||
if (!empty($filters['search'])) {
|
if (!empty($filters['search'])) {
|
||||||
$search = $filters['search'];
|
$search = $filters['search'];
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('products.code', 'like', "%{$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}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,22 @@ class StoreRequisitionService
|
|||||||
// 靜默建立以抑制自動日誌
|
// 靜默建立以抑制自動日誌
|
||||||
$requisition->saveQuietly();
|
$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' => []];
|
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$requisition->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'store_requisition_id' => $requisition->id,
|
||||||
'product_id' => $item['product_id'],
|
'product_id' => $item['product_id'],
|
||||||
'requested_qty' => $item['requested_qty'],
|
'requested_qty' => $item['requested_qty'],
|
||||||
'remark' => $item['remark'] ?? null,
|
'remark' => $item['remark'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
$product = \App\Modules\Inventory\Models\Product::find($item['product_id']);
|
$product = $products->get($item['product_id']);
|
||||||
$diff['added'][] = [
|
$diff['added'][] = [
|
||||||
'product_name' => $product?->name ?? '未知商品',
|
'product_name' => $product?->name ?? '未知商品',
|
||||||
'new' => [
|
'new' => [
|
||||||
@@ -70,6 +77,7 @@ class StoreRequisitionService
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
StoreRequisitionItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 如果需直接提交,觸發通知
|
// 如果需直接提交,觸發通知
|
||||||
if ($submitImmediately) {
|
if ($submitImmediately) {
|
||||||
@@ -179,13 +187,18 @@ class StoreRequisitionService
|
|||||||
|
|
||||||
// 儲存實際變動
|
// 儲存實際變動
|
||||||
$requisition->items()->delete();
|
$requisition->items()->delete();
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$requisition->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'store_requisition_id' => $requisition->id,
|
||||||
'product_id' => $item['product_id'],
|
'product_id' => $item['product_id'],
|
||||||
'requested_qty' => $item['requested_qty'],
|
'requested_qty' => $item['requested_qty'],
|
||||||
'remark' => $item['remark'] ?? null,
|
'remark' => $item['remark'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
StoreRequisitionItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 檢查是否有任何變動 (主表或明細)
|
// 檢查是否有任何變動 (主表或明細)
|
||||||
$isDirty = $requisition->isDirty();
|
$isDirty = $requisition->isDirty();
|
||||||
@@ -314,6 +327,7 @@ class StoreRequisitionService
|
|||||||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||||||
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
||||||
->where('product_id', $reqItem->product_id)
|
->where('product_id', $reqItem->product_id)
|
||||||
|
->lockForUpdate() // 補上鎖定
|
||||||
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
||||||
->value('available') ?? 0;
|
->value('available') ?? 0;
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,12 @@ class TransferService
|
|||||||
return [$key => $item];
|
return [$key => $item];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 釋放舊明細的預扣庫存
|
// 釋放舊明細的預扣庫存 (必須加鎖,防止並發更新時數量出錯)
|
||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
if ($inv) {
|
if ($inv) {
|
||||||
$inv->releaseReservedQuantity($item->quantity);
|
$inv->releaseReservedQuantity($item->quantity);
|
||||||
@@ -91,42 +92,69 @@ class TransferService
|
|||||||
'updated' => [],
|
'updated' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 先刪除舊明細
|
||||||
$order->items()->delete();
|
$order->items()->delete();
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
$newItemsKeys = [];
|
$newItemsKeys = [];
|
||||||
|
|
||||||
|
// 1. 批量收集待插入的明細數據
|
||||||
foreach ($itemsData as $data) {
|
foreach ($itemsData as $data) {
|
||||||
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
||||||
$newItemsKeys[] = $key;
|
$newItemsKeys[] = $key;
|
||||||
|
|
||||||
$item = $order->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'transfer_order_id' => $order->id,
|
||||||
'product_id' => $data['product_id'],
|
'product_id' => $data['product_id'],
|
||||||
'batch_number' => $data['batch_number'] ?? null,
|
'batch_number' => $data['batch_number'] ?? null,
|
||||||
'quantity' => $data['quantity'],
|
'quantity' => $data['quantity'],
|
||||||
'position' => $data['position'] ?? null,
|
'position' => $data['position'] ?? null,
|
||||||
'notes' => $data['notes'] ?? null,
|
'notes' => $data['notes'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$item->load('product');
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// 增加新明細的預扣庫存
|
// 2. 執行批量寫入 (提升效能:100 筆明細只需 1 次寫入)
|
||||||
$inv = Inventory::firstOrCreate(
|
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,
|
'warehouse_id' => $order->from_warehouse_id,
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
],
|
|
||||||
[
|
|
||||||
'quantity' => 0,
|
'quantity' => 0,
|
||||||
'unit_cost' => 0,
|
'unit_cost' => 0,
|
||||||
'total_value' => 0,
|
'total_value' => 0,
|
||||||
]
|
]);
|
||||||
);
|
$inv = $inv->fresh()->lockForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
$inv->reserveQuantity($item->quantity);
|
$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)) {
|
if ($oldItemsMap->has($key)) {
|
||||||
$oldItem = $oldItemsMap->get($key);
|
$oldItem = $oldItemsMap->get($key);
|
||||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
if ((float)$oldItem->quantity !== (float)$item->quantity ||
|
||||||
$oldItem->notes !== ($data['notes'] ?? null) ||
|
$oldItem->notes !== $item->notes ||
|
||||||
$oldItem->position !== ($data['position'] ?? null)) {
|
$oldItem->position !== $item->position) {
|
||||||
|
|
||||||
$diff['updated'][] = [
|
$diff['updated'][] = [
|
||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
@@ -137,7 +165,7 @@ class TransferService
|
|||||||
'notes' => $oldItem->notes,
|
'notes' => $oldItem->notes,
|
||||||
],
|
],
|
||||||
'new' => [
|
'new' => [
|
||||||
'quantity' => (float)$data['quantity'],
|
'quantity' => (float)$item->quantity,
|
||||||
'position' => $item->position,
|
'position' => $item->position,
|
||||||
'notes' => $item->notes,
|
'notes' => $item->notes,
|
||||||
]
|
]
|
||||||
@@ -158,8 +186,8 @@ class TransferService
|
|||||||
foreach ($oldItemsMap as $key => $oldItem) {
|
foreach ($oldItemsMap as $key => $oldItem) {
|
||||||
if (!in_array($key, $newItemsKeys)) {
|
if (!in_array($key, $newItemsKeys)) {
|
||||||
$diff['removed'][] = [
|
$diff['removed'][] = [
|
||||||
'product_name' => $oldItem->product->name,
|
'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})",
|
||||||
'unit_name' => $oldItem->product->baseUnit?->name,
|
'unit_name' => $oldItem->product?->baseUnit?->name,
|
||||||
'old' => [
|
'old' => [
|
||||||
'quantity' => (float)$oldItem->quantity,
|
'quantity' => (float)$oldItem->quantity,
|
||||||
'notes' => $oldItem->notes,
|
'notes' => $oldItem->notes,
|
||||||
@@ -179,9 +207,6 @@ class TransferService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
||||||
*
|
|
||||||
* 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched
|
|
||||||
* 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯)
|
|
||||||
*/
|
*/
|
||||||
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
||||||
{
|
{
|
||||||
@@ -194,18 +219,16 @@ class TransferService
|
|||||||
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
||||||
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
||||||
|
|
||||||
$outType = '調撥出庫';
|
|
||||||
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
|
||||||
|
|
||||||
$itemsDiff = [];
|
$itemsDiff = [];
|
||||||
|
|
||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
if ($item->quantity <= 0) continue;
|
if ($item->quantity <= 0) continue;
|
||||||
|
|
||||||
// 1. 處理來源倉 (扣除)
|
// 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣
|
||||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||||
@@ -235,11 +258,11 @@ class TransferService
|
|||||||
|
|
||||||
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
||||||
|
|
||||||
// 2. 處理目的倉/在途倉 (增加)
|
// 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition
|
||||||
// 獲取目的倉異動前的庫存數(若無則為 0)
|
|
||||||
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
|
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||||
|
|
||||||
@@ -310,7 +333,6 @@ class TransferService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
||||||
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
|
|
||||||
*/
|
*/
|
||||||
public function receive(InventoryTransferOrder $order, int $userId): void
|
public function receive(InventoryTransferOrder $order, int $userId): void
|
||||||
{
|
{
|
||||||
@@ -333,10 +355,11 @@ class TransferService
|
|||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
if ($item->quantity <= 0) continue;
|
if ($item->quantity <= 0) continue;
|
||||||
|
|
||||||
// 1. 在途倉扣除
|
// 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣
|
||||||
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
||||||
@@ -359,10 +382,11 @@ class TransferService
|
|||||||
|
|
||||||
$transitAfter = $transitBefore - (float) $item->quantity;
|
$transitAfter = $transitBefore - (float) $item->quantity;
|
||||||
|
|
||||||
// 2. 目的倉增加
|
// 2. 目的倉增加 - 同樣需要鎖定
|
||||||
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
|
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||||
|
|
||||||
@@ -440,6 +464,7 @@ class TransferService
|
|||||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
if ($inv) {
|
if ($inv) {
|
||||||
$inv->releaseReservedQuantity($item->quantity);
|
$inv->releaseReservedQuantity($item->quantity);
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ class TurnoverService
|
|||||||
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
|
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
|
||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
|
->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)
|
->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
|
||||||
->groupBy('inventories.product_id');
|
->groupBy('inventories.product_id');
|
||||||
|
|
||||||
@@ -87,6 +93,12 @@ class TurnoverService
|
|||||||
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
|
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
|
||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->where('inventory_transactions.type', '出庫')
|
->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');
|
->groupBy('inventories.product_id');
|
||||||
|
|
||||||
if ($warehouseId) {
|
if ($warehouseId) {
|
||||||
@@ -199,6 +211,12 @@ class TurnoverService
|
|||||||
// Get IDs of products sold in last 90 days
|
// Get IDs of products sold in last 90 days
|
||||||
$soldProductIds = InventoryTransaction::query()
|
$soldProductIds = InventoryTransaction::query()
|
||||||
->where('type', '出庫')
|
->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)
|
->where('actual_time', '>=', $ninetyDaysAgo)
|
||||||
->distinct()
|
->distinct()
|
||||||
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
|
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
|
||||||
@@ -214,6 +232,12 @@ class TurnoverService
|
|||||||
$soldProductIdsQuery = DB::table('inventory_transactions')
|
$soldProductIdsQuery = DB::table('inventory_transactions')
|
||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->where('inventory_transactions.type', '出庫')
|
->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)
|
->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
|
||||||
->select('inventories.product_id')
|
->select('inventories.product_id')
|
||||||
->distinct();
|
->distinct();
|
||||||
@@ -236,6 +260,12 @@ class TurnoverService
|
|||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||||
->where('inventory_transactions.type', '出庫')
|
->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))
|
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
|
||||||
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
|
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
|
||||||
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
|
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class PurchaseOrderController extends Controller
|
|||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
// 1. 獲取廠商(無關聯)
|
// 1. 獲取廠商(無關聯)
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
|
|
||||||
// 2. 手動注入:獲取 Pivot 資料
|
// 2. 手動注入:獲取 Pivot 資料
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
@@ -254,17 +254,21 @@ class PurchaseOrderController extends Controller
|
|||||||
$productIds = collect($validated['items'])->pluck('productId')->unique()->toArray();
|
$productIds = collect($validated['items'])->pluck('productId')->unique()->toArray();
|
||||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($validated['items'] as $item) {
|
foreach ($validated['items'] as $item) {
|
||||||
// 反算單價
|
// 反算單價
|
||||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||||
|
|
||||||
$order->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_order_id' => $order->id,
|
||||||
'product_id' => $item['productId'],
|
'product_id' => $item['productId'],
|
||||||
'quantity' => $item['quantity'],
|
'quantity' => $item['quantity'],
|
||||||
'unit_id' => $item['unitId'] ?? null,
|
'unit_id' => $item['unitId'] ?? null,
|
||||||
'unit_price' => $unitPrice,
|
'unit_price' => $unitPrice,
|
||||||
'subtotal' => $item['subtotal'],
|
'subtotal' => $item['subtotal'],
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
$product = $products->get($item['productId']);
|
$product = $products->get($item['productId']);
|
||||||
$diff['added'][] = [
|
$diff['added'][] = [
|
||||||
@@ -275,6 +279,7 @@ class PurchaseOrderController extends Controller
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 手動發送高品質日誌(包含品項明細)
|
// 手動發送高品質日誌(包含品項明細)
|
||||||
activity()
|
activity()
|
||||||
@@ -379,7 +384,7 @@ class PurchaseOrderController extends Controller
|
|||||||
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
||||||
|
|
||||||
// 2. 獲取廠商與商品(與 create 邏輯一致)
|
// 2. 獲取廠商與商品(與 create 邏輯一致)
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
||||||
$productIds = $pivots->pluck('product_id')->unique()->toArray();
|
$productIds = $pivots->pluck('product_id')->unique()->toArray();
|
||||||
@@ -468,7 +473,8 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, $id)
|
public function update(Request $request, $id)
|
||||||
{
|
{
|
||||||
$order = PurchaseOrder::findOrFail($id);
|
// 加上 lockForUpdate() 防止併發修改
|
||||||
|
$order = PurchaseOrder::lockForUpdate()->findOrFail($id);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'vendor_id' => 'required|exists:vendors,id',
|
'vendor_id' => 'required|exists:vendors,id',
|
||||||
@@ -572,20 +578,23 @@ class PurchaseOrderController extends Controller
|
|||||||
// 同步項目(原始邏輯)
|
// 同步項目(原始邏輯)
|
||||||
$order->items()->delete();
|
$order->items()->delete();
|
||||||
|
|
||||||
$newItemsData = [];
|
$itemsToInsert = [];
|
||||||
foreach ($validated['items'] as $item) {
|
foreach ($validated['items'] as $item) {
|
||||||
// 反算單價
|
// 反算單價
|
||||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||||
|
|
||||||
$newItem = $order->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_order_id' => $order->id,
|
||||||
'product_id' => $item['productId'],
|
'product_id' => $item['productId'],
|
||||||
'quantity' => $item['quantity'],
|
'quantity' => $item['quantity'],
|
||||||
'unit_id' => $item['unitId'] ?? null,
|
'unit_id' => $item['unitId'] ?? null,
|
||||||
'unit_price' => $unitPrice,
|
'unit_price' => $unitPrice,
|
||||||
'subtotal' => $item['subtotal'],
|
'subtotal' => $item['subtotal'],
|
||||||
]);
|
'created_at' => now(),
|
||||||
$newItemsData[] = $newItem;
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 3. 計算項目差異
|
// 3. 計算項目差異
|
||||||
$itemDiffs = [
|
$itemDiffs = [
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class PurchaseReturnController extends Controller
|
|||||||
{
|
{
|
||||||
// 取得可用的倉庫與廠商資料供前端選單使用
|
// 取得可用的倉庫與廠商資料供前端選單使用
|
||||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
|
|
||||||
// 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致)
|
// 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致)
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
@@ -157,7 +157,7 @@ class PurchaseReturnController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
|
|
||||||
// 手動注入:獲取廠商商品 (與 create 邏輯一致)
|
// 手動注入:獲取廠商商品 (與 create 邏輯一致)
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class PurchaseOrderItem extends Model
|
|||||||
'purchase_order_id',
|
'purchase_order_id',
|
||||||
'product_id',
|
'product_id',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'unit_id',
|
||||||
'unit_price',
|
'unit_price',
|
||||||
'subtotal',
|
'subtotal',
|
||||||
// 驗收欄位
|
// 驗收欄位
|
||||||
|
|||||||
@@ -33,20 +33,23 @@ class PurchaseReturnService
|
|||||||
|
|
||||||
$purchaseReturn = PurchaseReturn::create($data);
|
$purchaseReturn = PurchaseReturn::create($data);
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
||||||
$totalAmount += $amount;
|
$totalAmount += $amount;
|
||||||
|
|
||||||
$prItem = new PurchaseReturnItem([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_return_id' => $purchaseReturn->id,
|
||||||
'product_id' => $itemData['product_id'],
|
'product_id' => $itemData['product_id'],
|
||||||
'quantity_returned' => $itemData['quantity_returned'],
|
'quantity_returned' => $itemData['quantity_returned'],
|
||||||
'unit_price' => $itemData['unit_price'],
|
'unit_price' => $itemData['unit_price'],
|
||||||
'total_amount' => $amount,
|
'total_amount' => $amount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
$purchaseReturn->items()->save($prItem);
|
];
|
||||||
}
|
}
|
||||||
|
PurchaseReturnItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount)
|
// 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount)
|
||||||
$taxAmount = $data['tax_amount'] ?? 0;
|
$taxAmount = $data['tax_amount'] ?? 0;
|
||||||
@@ -87,19 +90,23 @@ class PurchaseReturnService
|
|||||||
$purchaseReturn->items()->delete();
|
$purchaseReturn->items()->delete();
|
||||||
$totalAmount = 0;
|
$totalAmount = 0;
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
||||||
$totalAmount += $amount;
|
$totalAmount += $amount;
|
||||||
|
|
||||||
$prItem = new PurchaseReturnItem([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_return_id' => $purchaseReturn->id,
|
||||||
'product_id' => $itemData['product_id'],
|
'product_id' => $itemData['product_id'],
|
||||||
'quantity_returned' => $itemData['quantity_returned'],
|
'quantity_returned' => $itemData['quantity_returned'],
|
||||||
'unit_price' => $itemData['unit_price'],
|
'unit_price' => $itemData['unit_price'],
|
||||||
'total_amount' => $amount,
|
'total_amount' => $amount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$purchaseReturn->items()->save($prItem);
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
PurchaseReturnItem::insert($itemsToInsert);
|
||||||
|
|
||||||
$taxAmount = $purchaseReturn->tax_amount;
|
$taxAmount = $purchaseReturn->tax_amount;
|
||||||
$purchaseReturn->update([
|
$purchaseReturn->update([
|
||||||
@@ -117,11 +124,14 @@ class PurchaseReturnService
|
|||||||
*/
|
*/
|
||||||
public function submit(PurchaseReturn $purchaseReturn)
|
public function submit(PurchaseReturn $purchaseReturn)
|
||||||
{
|
{
|
||||||
|
return DB::transaction(function () use ($purchaseReturn) {
|
||||||
|
// 加上 lockForUpdate() 防止併發提交
|
||||||
|
$purchaseReturn = PurchaseReturn::lockForUpdate()->find($purchaseReturn->id);
|
||||||
|
|
||||||
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
|
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
|
||||||
throw new Exception('只有草稿狀態的退回單可以提交。');
|
throw new Exception('只有草稿狀態的退回單可以提交。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($purchaseReturn) {
|
|
||||||
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
|
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
|
||||||
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
|
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
|
||||||
$purchaseReturn->saveQuietly();
|
$purchaseReturn->saveQuietly();
|
||||||
|
|||||||
@@ -67,21 +67,46 @@ class ProductionOrderController extends Controller
|
|||||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||||
$perPage = $defaultPerPage;
|
$perPage = $defaultPerPage;
|
||||||
}
|
}
|
||||||
$productionOrders = $query->paginate($perPage)->withQueryString();
|
$productionOrders = $query->with('items')->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
// --- 手動資料水和 (Manual Hydration) ---
|
// --- 手動資料水和 (Manual Hydration) ---
|
||||||
$productIds = $productionOrders->pluck('product_id')->unique()->filter()->toArray();
|
$productIds = collect();
|
||||||
$warehouseIds = $productionOrders->pluck('warehouse_id')->unique()->filter()->toArray();
|
$warehouseIds = collect();
|
||||||
$userIds = $productionOrders->pluck('user_id')->unique()->filter()->toArray();
|
$userIds = collect();
|
||||||
|
$inventoryIds = collect();
|
||||||
|
|
||||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
foreach ($productionOrders as $order) {
|
||||||
$warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id');
|
$productIds->push($order->product_id);
|
||||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
$warehouseIds->push($order->warehouse_id);
|
||||||
|
$userIds->push($order->user_id);
|
||||||
|
if ($order->items) {
|
||||||
|
$inventoryIds = $inventoryIds->merge($order->items->pluck('inventory_id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users) {
|
$products = $this->inventoryService->getProductsByIds($productIds->unique()->filter()->toArray())->keyBy('id');
|
||||||
|
$warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds->unique()->filter()->toArray())->keyBy('id');
|
||||||
|
$users = $this->coreService->getUsersByIds($userIds->unique()->filter()->toArray())->keyBy('id');
|
||||||
|
$inventories = $this->inventoryService->getInventoriesByIds($inventoryIds->unique()->filter()->toArray())->keyBy('id');
|
||||||
|
|
||||||
|
$productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users, $inventories) {
|
||||||
$order->product = $products->get($order->product_id);
|
$order->product = $products->get($order->product_id);
|
||||||
$order->warehouse = $warehouses->get($order->warehouse_id);
|
$order->warehouse = $warehouses->get($order->warehouse_id);
|
||||||
$order->user = $users->get($order->user_id);
|
$order->user = $users->get($order->user_id);
|
||||||
|
|
||||||
|
$totalCost = 0;
|
||||||
|
if ($order->items) {
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
$inventory = $inventories->get($item->inventory_id);
|
||||||
|
if ($inventory) {
|
||||||
|
$totalCost += $item->quantity_used * ($inventory->unit_cost ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$order->estimated_total_cost = $totalCost;
|
||||||
|
$order->estimated_unit_cost = $order->output_quantity > 0 ? $totalCost / $order->output_quantity : 0;
|
||||||
|
unset($order->items);
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,12 +137,12 @@ class ProductionOrderController extends Controller
|
|||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'product_id' => 'required',
|
'product_id' => 'required',
|
||||||
'status' => 'nullable|in:draft,completed',
|
'status' => 'nullable|in:draft,pending,completed',
|
||||||
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable',
|
'warehouse_id' => 'required',
|
||||||
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric',
|
'output_quantity' => 'required|numeric|min:0.01',
|
||||||
'items' => 'nullable|array',
|
'items' => 'nullable|array',
|
||||||
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable',
|
'items.*.inventory_id' => 'required',
|
||||||
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric',
|
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
||||||
];
|
];
|
||||||
|
|
||||||
$validated = $request->validate($rules);
|
$validated = $request->validate($rules);
|
||||||
@@ -134,7 +159,7 @@ class ProductionOrderController extends Controller
|
|||||||
'production_date' => $request->production_date,
|
'production_date' => $request->production_date,
|
||||||
'expiry_date' => $request->expiry_date,
|
'expiry_date' => $request->expiry_date,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿
|
'status' => $status ?: ProductionOrder::STATUS_DRAFT,
|
||||||
'remark' => $request->remark,
|
'remark' => $request->remark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -145,14 +170,18 @@ class ProductionOrderController extends Controller
|
|||||||
|
|
||||||
// 2. 處理明細
|
// 2. 處理明細
|
||||||
if (!empty($request->items)) {
|
if (!empty($request->items)) {
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($request->items as $item) {
|
foreach ($request->items as $item) {
|
||||||
ProductionOrderItem::create([
|
$itemsToInsert[] = [
|
||||||
'production_order_id' => $productionOrder->id,
|
'production_order_id' => $productionOrder->id,
|
||||||
'inventory_id' => $item['inventory_id'],
|
'inventory_id' => $item['inventory_id'],
|
||||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
'unit_id' => $item['unit_id'] ?? null,
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
ProductionOrderItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +260,9 @@ class ProductionOrderController extends Controller
|
|||||||
'unit_name' => $inv->product->baseUnit->name ?? '',
|
'unit_name' => $inv->product->baseUnit->name ?? '',
|
||||||
'base_unit_id' => $inv->product->base_unit_id ?? null,
|
'base_unit_id' => $inv->product->base_unit_id ?? null,
|
||||||
'large_unit_id' => $inv->product->large_unit_id ?? null,
|
'large_unit_id' => $inv->product->large_unit_id ?? null,
|
||||||
|
'purchase_unit_id' => $inv->product->purchase_unit_id ?? null,
|
||||||
'conversion_rate' => $inv->product->conversion_rate ?? 1,
|
'conversion_rate' => $inv->product->conversion_rate ?? 1,
|
||||||
|
'unit_cost' => (float) $inv->unit_cost,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +289,10 @@ class ProductionOrderController extends Controller
|
|||||||
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
'unit_name' => $inv->product->baseUnit->name ?? '',
|
'unit_name' => $inv->product->baseUnit->name ?? '',
|
||||||
'base_unit_id' => $inv->product->base_unit_id ?? null,
|
'base_unit_id' => $inv->product->base_unit_id ?? null,
|
||||||
|
'large_unit_id' => $inv->product->large_unit_id ?? null,
|
||||||
|
'purchase_unit_id' => $inv->product->purchase_unit_id ?? null,
|
||||||
'conversion_rate' => $inv->product->conversion_rate ?? 1,
|
'conversion_rate' => $inv->product->conversion_rate ?? 1,
|
||||||
|
'unit_cost' => (float) $inv->unit_cost,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,14 +384,18 @@ class ProductionOrderController extends Controller
|
|||||||
$productionOrder->items()->delete();
|
$productionOrder->items()->delete();
|
||||||
|
|
||||||
if (!empty($request->items)) {
|
if (!empty($request->items)) {
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($request->items as $item) {
|
foreach ($request->items as $item) {
|
||||||
ProductionOrderItem::create([
|
$itemsToInsert[] = [
|
||||||
'production_order_id' => $productionOrder->id,
|
'production_order_id' => $productionOrder->id,
|
||||||
'inventory_id' => $item['inventory_id'],
|
'inventory_id' => $item['inventory_id'],
|
||||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
'unit_id' => $item['unit_id'] ?? null,
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
ProductionOrderItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,9 +414,30 @@ class ProductionOrderController extends Controller
|
|||||||
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
|
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 送審前的資料完整性驗證
|
||||||
|
if ($productionOrder->status === ProductionOrder::STATUS_DRAFT && $newStatus === ProductionOrder::STATUS_PENDING) {
|
||||||
|
if (!$productionOrder->output_quantity || $productionOrder->output_quantity <= 0) {
|
||||||
|
return back()->with('error', '送審工單前,必須先編輯填寫「生產數量」');
|
||||||
|
}
|
||||||
|
if (!$productionOrder->warehouse_id) {
|
||||||
|
return back()->with('error', '送審工單前,必須先編輯選擇「預計入庫倉庫」');
|
||||||
|
}
|
||||||
|
if ($productionOrder->items()->count() === 0) {
|
||||||
|
return back()->with('error', '送審工單前,請至少新增一項原物料明細');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($newStatus, $productionOrder, $request) {
|
DB::transaction(function () use ($newStatus, $productionOrder, $request) {
|
||||||
|
// 使用鎖定重新獲取單據,防止併發狀態修改
|
||||||
|
$productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first();
|
||||||
|
|
||||||
$oldStatus = $productionOrder->status;
|
$oldStatus = $productionOrder->status;
|
||||||
|
|
||||||
|
// 再次檢查狀態轉移(在鎖定後)
|
||||||
|
if (!$productionOrder->canTransitionTo($newStatus)) {
|
||||||
|
throw new \Exception('不合法的狀態轉移或權限不足');
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 執行特定狀態的業務邏輯
|
// 1. 執行特定狀態的業務邏輯
|
||||||
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
|
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
|
||||||
// 開始製作 -> 扣除原料庫存
|
// 開始製作 -> 扣除原料庫存
|
||||||
@@ -398,6 +457,8 @@ class ProductionOrderController extends Controller
|
|||||||
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
|
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
|
||||||
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
|
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
|
||||||
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
|
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
|
||||||
|
$actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量
|
||||||
|
$lossReason = $request->input('loss_reason'); // 耗損原因
|
||||||
|
|
||||||
if (!$warehouseId) {
|
if (!$warehouseId) {
|
||||||
throw new \Exception('必須選擇入庫倉庫');
|
throw new \Exception('必須選擇入庫倉庫');
|
||||||
@@ -405,17 +466,41 @@ class ProductionOrderController extends Controller
|
|||||||
if (!$batchNumber) {
|
if (!$batchNumber) {
|
||||||
throw new \Exception('必須提供成品批號');
|
throw new \Exception('必須提供成品批號');
|
||||||
}
|
}
|
||||||
|
if (!$actualOutputQuantity || $actualOutputQuantity <= 0) {
|
||||||
|
throw new \Exception('實際產出數量必須大於 0');
|
||||||
|
}
|
||||||
|
if ($actualOutputQuantity > $productionOrder->output_quantity) {
|
||||||
|
throw new \Exception('實際產出數量不可大於預計產量');
|
||||||
|
}
|
||||||
|
|
||||||
// 更新單據資訊:批號、效期與自動記錄生產日期
|
// --- 計算原物料投入總成本 ---
|
||||||
|
$totalCost = 0;
|
||||||
|
$items = $productionOrder->items()->with('inventory')->get();
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item->inventory) {
|
||||||
|
$totalCost += ($item->quantity_used * ($item->inventory->unit_cost ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 單位成本以「實際產出數量」為分母,反映真實生產效率
|
||||||
|
$unitCost = $actualOutputQuantity > 0
|
||||||
|
? $totalCost / $actualOutputQuantity
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 更新單據資訊:批號、效期、實際產量與耗損原因
|
||||||
$productionOrder->output_batch_number = $batchNumber;
|
$productionOrder->output_batch_number = $batchNumber;
|
||||||
$productionOrder->expiry_date = $expiryDate;
|
$productionOrder->expiry_date = $expiryDate;
|
||||||
$productionOrder->production_date = now()->toDateString();
|
$productionOrder->production_date = now()->toDateString();
|
||||||
$productionOrder->warehouse_id = $warehouseId;
|
$productionOrder->warehouse_id = $warehouseId;
|
||||||
|
$productionOrder->actual_output_quantity = $actualOutputQuantity;
|
||||||
|
$productionOrder->loss_reason = $lossReason;
|
||||||
|
|
||||||
|
// 成品入庫數量改用「實際產出數量」
|
||||||
$this->inventoryService->createInventoryRecord([
|
$this->inventoryService->createInventoryRecord([
|
||||||
'warehouse_id' => $warehouseId,
|
'warehouse_id' => $warehouseId,
|
||||||
'product_id' => $productionOrder->product_id,
|
'product_id' => $productionOrder->product_id,
|
||||||
'quantity' => $productionOrder->output_quantity,
|
'quantity' => $actualOutputQuantity,
|
||||||
|
'unit_cost' => $unitCost,
|
||||||
'batch_number' => $batchNumber,
|
'batch_number' => $batchNumber,
|
||||||
'box_number' => $productionOrder->output_box_count,
|
'box_number' => $productionOrder->output_box_count,
|
||||||
'arrival_date' => now()->toDateString(),
|
'arrival_date' => now()->toDateString(),
|
||||||
|
|||||||
@@ -46,14 +46,51 @@ class RecipeController extends Controller
|
|||||||
$perPage = $defaultPerPage;
|
$perPage = $defaultPerPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
$recipes = $query->paginate($perPage)->withQueryString();
|
$recipes = $query->with('items')->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
// Manual Hydration
|
// Manual Hydration
|
||||||
$productIds = $recipes->pluck('product_id')->unique()->filter()->toArray();
|
$productIds = collect();
|
||||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
$itemProductIds = collect();
|
||||||
|
|
||||||
|
foreach ($recipes as $recipe) {
|
||||||
|
$productIds->push($recipe->product_id);
|
||||||
|
if ($recipe->items) {
|
||||||
|
$itemProductIds = $itemProductIds->merge($recipe->items->pluck('product_id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allProductIds = array_unique(array_merge(
|
||||||
|
$productIds->unique()->filter()->toArray(),
|
||||||
|
$itemProductIds->unique()->filter()->toArray()
|
||||||
|
));
|
||||||
|
|
||||||
|
$products = $this->inventoryService->getProductsByIds($allProductIds)->keyBy('id');
|
||||||
|
|
||||||
$recipes->getCollection()->transform(function ($recipe) use ($products) {
|
$recipes->getCollection()->transform(function ($recipe) use ($products) {
|
||||||
$recipe->product = $products->get($recipe->product_id);
|
$recipe->product = $products->get($recipe->product_id);
|
||||||
|
|
||||||
|
$totalCost = 0;
|
||||||
|
if ($recipe->items) {
|
||||||
|
foreach ($recipe->items as $item) {
|
||||||
|
$itemProduct = $products->get($item->product_id);
|
||||||
|
if ($itemProduct) {
|
||||||
|
$baseCost = $itemProduct->cost_price ?? 0;
|
||||||
|
$conversionRate = 1;
|
||||||
|
|
||||||
|
if ($item->unit_id == $itemProduct->large_unit_id && !is_null($itemProduct->conversion_rate)) {
|
||||||
|
$conversionRate = $itemProduct->conversion_rate;
|
||||||
|
} elseif ($item->unit_id == $itemProduct->purchase_unit_id && !is_null($itemProduct->conversion_rate_purchase)) {
|
||||||
|
$conversionRate = $itemProduct->conversion_rate_purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCost += ($item->quantity * $baseCost * $conversionRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$recipe->estimated_total_cost = $totalCost;
|
||||||
|
$recipe->estimated_unit_cost = $recipe->yield_quantity > 0 ? $totalCost / $recipe->yield_quantity : 0;
|
||||||
|
unset($recipe->items);
|
||||||
|
|
||||||
return $recipe;
|
return $recipe;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class ProductionOrder extends Model
|
|||||||
'product_id',
|
'product_id',
|
||||||
'warehouse_id',
|
'warehouse_id',
|
||||||
'output_quantity',
|
'output_quantity',
|
||||||
|
'actual_output_quantity',
|
||||||
|
'loss_reason',
|
||||||
'output_batch_number',
|
'output_batch_number',
|
||||||
'output_box_count',
|
'output_box_count',
|
||||||
'production_date',
|
'production_date',
|
||||||
@@ -82,6 +84,7 @@ class ProductionOrder extends Model
|
|||||||
'production_date' => 'date',
|
'production_date' => 'date',
|
||||||
'expiry_date' => 'date',
|
'expiry_date' => 'date',
|
||||||
'output_quantity' => 'decimal:2',
|
'output_quantity' => 'decimal:2',
|
||||||
|
'actual_output_quantity' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getActivitylogOptions(): LogOptions
|
public function getActivitylogOptions(): LogOptions
|
||||||
@@ -91,6 +94,8 @@ class ProductionOrder extends Model
|
|||||||
'code',
|
'code',
|
||||||
'status',
|
'status',
|
||||||
'output_quantity',
|
'output_quantity',
|
||||||
|
'actual_output_quantity',
|
||||||
|
'loss_reason',
|
||||||
'output_batch_number',
|
'output_batch_number',
|
||||||
'production_date',
|
'production_date',
|
||||||
'remark'
|
'remark'
|
||||||
|
|||||||
@@ -101,11 +101,13 @@ class SalesImportController extends Controller
|
|||||||
|
|
||||||
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
|
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
|
||||||
{
|
{
|
||||||
if ($import->status !== 'pending') {
|
return DB::transaction(function () use ($import, $inventoryService) {
|
||||||
return back()->with('error', '此批次無法確認。');
|
// 加上 lockForUpdate() 防止併發確認
|
||||||
}
|
$import = SalesImportBatch::lockForUpdate()->find($import->id);
|
||||||
|
|
||||||
DB::transaction(function () use ($import, $inventoryService) {
|
if (!$import || $import->status !== 'pending') {
|
||||||
|
throw new \Exception('此批次無法確認或已被處理。');
|
||||||
|
}
|
||||||
// 1. Prepare Aggregation
|
// 1. Prepare Aggregation
|
||||||
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
|
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
|
||||||
|
|
||||||
@@ -155,7 +157,9 @@ class SalesImportController extends Controller
|
|||||||
$deduction['quantity'],
|
$deduction['quantity'],
|
||||||
$reason,
|
$reason,
|
||||||
true, // Force deduction
|
true, // Force deduction
|
||||||
$deduction['slot'] // Location/Slot
|
$deduction['slot'], // Location/Slot
|
||||||
|
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||||
|
$import->id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('utility_fees', function (Blueprint $table) {
|
||||||
|
// 1. 將費用日期改為可為空 (繳費日期)
|
||||||
|
$table->date('transaction_date')->nullable()->change();
|
||||||
|
|
||||||
|
// 2. 新增繳費期限
|
||||||
|
$table->date('due_date')->nullable()->after('transaction_date')->comment('繳費期限');
|
||||||
|
|
||||||
|
// 3. 新增繳費狀態
|
||||||
|
$table->enum('payment_status', ['pending', 'paid', 'overdue'])
|
||||||
|
->default('pending')
|
||||||
|
->after('amount')
|
||||||
|
->comment('繳費狀態');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('utility_fees', function (Blueprint $table) {
|
||||||
|
$table->date('transaction_date')->nullable(false)->change();
|
||||||
|
$table->dropColumn(['due_date', 'payment_status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('utility_fee_attachments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('utility_fee_id')->constrained('utility_fees')->cascadeOnDelete();
|
||||||
|
$table->string('file_path'); // 儲存路徑
|
||||||
|
$table->string('original_name'); // 原始檔名
|
||||||
|
$table->string('mime_type'); // MIME 類型
|
||||||
|
$table->unsignedBigInteger('size'); // 檔案大小 (bytes)
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('utility_fee_attachments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 1. warehouses (倉庫)
|
||||||
|
Schema::table('warehouses', function (Blueprint $table) {
|
||||||
|
$table->index('type');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. categories (分類)
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->index('is_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. products (商品/原物料)
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
// is_active was added in a later migration, need to make sure column exists before indexing
|
||||||
|
// Same for brand if not added at start (but brand is in the create migration)
|
||||||
|
if (Schema::hasColumn('products', 'is_active')) {
|
||||||
|
$table->index('is_active');
|
||||||
|
}
|
||||||
|
$table->index('brand');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. recipes (配方/BOM)
|
||||||
|
Schema::table('recipes', function (Blueprint $table) {
|
||||||
|
$table->index('is_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. inventory_transactions (庫存異動紀錄)
|
||||||
|
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||||
|
$table->index('type');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. purchase_orders (採購單)
|
||||||
|
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('expected_delivery_date');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. production_orders (生產工單)
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. sales_orders (門市/銷售單)
|
||||||
|
Schema::table('sales_orders', function (Blueprint $table) {
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('sold_at');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('warehouses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['is_active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('products', 'is_active')) {
|
||||||
|
$table->dropIndex(['is_active']);
|
||||||
|
}
|
||||||
|
$table->dropIndex(['brand']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('recipes', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['is_active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['type']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['expected_delivery_date']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('sales_orders', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['sold_at']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 為生產工單新增「實際產出數量」與「耗損原因」欄位。
|
||||||
|
* 實際產出數量用於記錄完工時的真實產量(可能因耗損低於預計產量)。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->decimal('actual_output_quantity', 10, 2)
|
||||||
|
->nullable()
|
||||||
|
->after('output_quantity')
|
||||||
|
->comment('實際產出數量(預設等於 output_quantity,可於完工時調降)');
|
||||||
|
|
||||||
|
$table->string('loss_reason', 255)
|
||||||
|
->nullable()
|
||||||
|
->after('actual_output_quantity')
|
||||||
|
->comment('耗損原因說明');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['actual_output_quantity', 'loss_reason']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -56,6 +56,28 @@ class SystemSettingSeeder extends Seeder
|
|||||||
'type' => 'integer',
|
'type' => 'integer',
|
||||||
'description' => '每頁預設筆數',
|
'description' => '每頁預設筆數',
|
||||||
],
|
],
|
||||||
|
// 📧 通知設定
|
||||||
|
[
|
||||||
|
'group' => 'notification',
|
||||||
|
'key' => 'notification.utility_fee_sender_email',
|
||||||
|
'value' => 'sky121113@gmail.com',
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '發送公共事業通知的 Gmail 帳號',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'group' => 'notification',
|
||||||
|
'key' => 'notification.utility_fee_sender_password',
|
||||||
|
'value' => 'qjxcedzcrjoyioxu',
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '發送公共事業通知的 Gmail 應用程式密碼',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'group' => 'notification',
|
||||||
|
'key' => 'notification.utility_fee_recipient_emails',
|
||||||
|
'value' => 'sky121113@gmail.com',
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => '接收通知的 Email 清單 (多筆請用逗號分隔)',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($settings as $setting) {
|
foreach ($settings as $setting) {
|
||||||
|
|||||||
34
e2e/admin.spec.ts
Normal file
34
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('系統管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入角色權限管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/roles');
|
||||||
|
await expect(page.locator('h1').filter({ hasText: '角色與權限' })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增角色/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入員工帳號管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await expect(page.getByRole('heading', { name: /使用者管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增使用者/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入系統操作紀錄頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/activity-logs');
|
||||||
|
await expect(page.getByRole('heading', { name: /操作紀錄/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入系統參數設定頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/settings');
|
||||||
|
await expect(page.locator('h1').filter({ hasText: '系統設定' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /存檔|儲存/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
28
e2e/finance.spec.ts
Normal file
28
e2e/finance.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('財務管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入應付帳款管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/finance/account-payables');
|
||||||
|
await expect(page.getByRole('heading', { name: /應付帳款管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入水電瓦斯費管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/utility-fees');
|
||||||
|
await expect(page.getByRole('heading', { name: /公共事業費管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入財務報表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/accounting-report');
|
||||||
|
await expect(page.getByRole('heading', { name: /會計報表/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /匯出/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/helpers/auth.ts
Normal file
15
e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 共用登入函式
|
||||||
|
* 使用測試帳號登入 Star ERP 系統
|
||||||
|
*/
|
||||||
|
export async function login(page: Page, username = 'admin', password = 'password') {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.fill('#username', username);
|
||||||
|
await page.fill('#password', password);
|
||||||
|
await page.getByRole('button', { name: '登入系統' }).click();
|
||||||
|
// 等待儀表板載入完成 (改用更穩定的側邊欄文字或 URL)
|
||||||
|
await page.waitForURL('**/');
|
||||||
|
await expect(page.getByRole('link', { name: '儀表板' }).first()).toBeVisible({ timeout: 15000 });
|
||||||
|
}
|
||||||
14
e2e/integration.spec.ts
Normal file
14
e2e/integration.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('系統串接模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入銷貨單據串接頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/integration/sales-orders');
|
||||||
|
await expect(page.locator('h1').filter({ hasText: '銷售訂單管理' })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
e2e/inventory.spec.ts
Normal file
81
e2e/inventory.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 庫存模組端到端測試
|
||||||
|
*/
|
||||||
|
test.describe('庫存管理 - 調撥單匯入', () => {
|
||||||
|
// 登入 + 導航 + 匯入全流程需要較長時間
|
||||||
|
test.use({ actionTimeout: 15000 });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能成功匯入調撥單明細', async ({ page }) => {
|
||||||
|
// 整體測試逾時設定為 60 秒
|
||||||
|
test.setTimeout(60000);
|
||||||
|
// 1. 前往調撥單列表
|
||||||
|
await page.goto('/inventory/transfer-orders');
|
||||||
|
await expect(page.getByText('庫存調撥管理')).toBeVisible();
|
||||||
|
|
||||||
|
// 2. 等待表格載入並尋找特定的 E2E 測試單據
|
||||||
|
await page.waitForSelector('table tbody tr');
|
||||||
|
|
||||||
|
const draftRow = page.locator('tr:has-text("TRF-E2E-FINAL")').first();
|
||||||
|
const hasDraft = await draftRow.count() > 0;
|
||||||
|
|
||||||
|
if (hasDraft) {
|
||||||
|
// 點擊 "編輯" 按鈕
|
||||||
|
await draftRow.locator('button[title="編輯"], a:has-text("編輯")').first().click();
|
||||||
|
} else {
|
||||||
|
throw new Error('測試環境中找不到單號為 TRF-E2E-FINAL 的調撥單。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 驗證已進入詳情頁 (標題包含調撥單單號)
|
||||||
|
await expect(page.getByRole('heading', { name: /調撥單: TRF-/ })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 4. 開啟匯入對話框
|
||||||
|
const importBtn = page.getByRole('button', { name: /匯入 Excel|匯入/ });
|
||||||
|
await expect(importBtn).toBeVisible();
|
||||||
|
await importBtn.click();
|
||||||
|
|
||||||
|
await expect(page.getByText('匯入調撥明細')).toBeVisible();
|
||||||
|
|
||||||
|
// 5. 準備測試檔案 (CSV 格式)
|
||||||
|
const csvPath = path.join('/tmp', 'transfer_import_test.csv');
|
||||||
|
// 欄位名稱必須與後端匹配,商品代碼使用 P2 (紅糖)
|
||||||
|
const csvContent = "商品代碼,數量,批號,備註\nP2,10,BATCH001,E2E Test Import\n";
|
||||||
|
fs.writeFileSync(csvPath, csvContent);
|
||||||
|
|
||||||
|
// 6. 執行上傳
|
||||||
|
await page.setInputFiles('input[type="file"]', csvPath);
|
||||||
|
|
||||||
|
// 7. 點擊開始匯入
|
||||||
|
await page.getByRole('button', { name: '開始匯入' }).click();
|
||||||
|
|
||||||
|
// 8. 等待頁面更新 (Inertia reload)
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// 9. 驗證詳情頁表格是否出現匯入的資料
|
||||||
|
// 注意:「E2E Test Import」是 input 的 value,不是靜態文字,hasText 無法匹配 input value
|
||||||
|
// 因此先找包含 P2 文字的行(P2 是靜態 text),再驗證備註 input 的值
|
||||||
|
const p2Row = page.locator('table tbody tr').filter({ hasText: 'P2' }).first();
|
||||||
|
await expect(p2Row).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 驗證備註欄位的 input value 包含測試標記
|
||||||
|
// 快照中備註欄位的 role 是 textbox,placeholder 是 "備註..."
|
||||||
|
const remarkInput = p2Row.getByRole('textbox', { name: '備註...' });
|
||||||
|
await expect(remarkInput).toHaveValue('E2E Test Import');
|
||||||
|
|
||||||
|
// 截圖留存
|
||||||
|
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||||
|
await page.screenshot({ path: 'e2e/screenshots/inventory-transfer-import-success.png', fullPage: true });
|
||||||
|
|
||||||
|
// 清理臨時檔案
|
||||||
|
if (fs.existsSync(csvPath)) fs.unlinkSync(csvPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
72
e2e/login.spec.ts
Normal file
72
e2e/login.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Star ERP 登入流程端到端測試
|
||||||
|
*/
|
||||||
|
test.describe('登入功能', () => {
|
||||||
|
|
||||||
|
test('頁面應正確顯示登入表單', async ({ page }) => {
|
||||||
|
// 前往登入頁面
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 驗證:帳號輸入框存在
|
||||||
|
await expect(page.locator('#username')).toBeVisible();
|
||||||
|
|
||||||
|
// 驗證:密碼輸入框存在
|
||||||
|
await expect(page.locator('#password')).toBeVisible();
|
||||||
|
|
||||||
|
// 驗證:登入按鈕存在
|
||||||
|
await expect(page.getByRole('button', { name: '登入系統' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('輸入錯誤的帳密應顯示錯誤訊息', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 輸入錯誤的帳號密碼
|
||||||
|
await page.fill('#username', 'wronguser');
|
||||||
|
await page.fill('#password', 'wrongpassword');
|
||||||
|
|
||||||
|
// 點擊登入
|
||||||
|
await page.getByRole('button', { name: '登入系統' }).click();
|
||||||
|
|
||||||
|
// 等待頁面回應
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 驗證:應該還是停在登入頁面(未成功跳轉)
|
||||||
|
await expect(page).toHaveURL(/\//);
|
||||||
|
|
||||||
|
// 驗證:頁面上應顯示某種錯誤提示(紅色文字或 toast)
|
||||||
|
// 先用寬鬆的檢查方式,後續可以根據實際錯誤訊息調整
|
||||||
|
const hasError = await page.locator('.text-red-500, .text-red-600, [role="alert"], .toast, [data-sonner-toast]').count();
|
||||||
|
expect(hasError).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('輸入正確帳密後應成功登入並跳轉', async ({ page }) => {
|
||||||
|
// 使用共用登入函式
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
// 驗證:頁面右上角應顯示使用者名稱
|
||||||
|
await expect(page.getByText('mama')).toBeVisible();
|
||||||
|
|
||||||
|
// 驗證:儀表板的關鍵指標卡片存在
|
||||||
|
await expect(page.getByText('庫存總值')).toBeVisible();
|
||||||
|
|
||||||
|
// 截圖留存(成功登入畫面)
|
||||||
|
await page.screenshot({ path: 'e2e/screenshots/login-success.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空白帳密不應能登入', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 不填任何東西,直接點登入
|
||||||
|
await page.getByRole('button', { name: '登入系統' }).click();
|
||||||
|
|
||||||
|
// 等待頁面回應
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// 驗證:應該還在登入頁面
|
||||||
|
await expect(page.locator('#username')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
127
e2e/procurement.spec.ts
Normal file
127
e2e/procurement.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 採購模組端到端測試
|
||||||
|
* 驗證「批量寫入」(多筆明細 bulk insert) 與「併發鎖定」(狀態變更 lockForUpdate)
|
||||||
|
*/
|
||||||
|
test.describe('採購管理 - 採購單建立', () => {
|
||||||
|
// 登入 + 導航 + 表單操作需要較長時間
|
||||||
|
test.use({ actionTimeout: 15000 });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能成功建立含多筆明細的採購單', async ({ page }) => {
|
||||||
|
// 整體測試逾時設定為 90 秒(含多次選單互動)
|
||||||
|
test.setTimeout(90000);
|
||||||
|
|
||||||
|
// 1. 前往採購單列表
|
||||||
|
await page.goto('/purchase-orders');
|
||||||
|
await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible();
|
||||||
|
|
||||||
|
// 2. 點擊「建立採購單」按鈕
|
||||||
|
await page.getByRole('button', { name: /建立採購單/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: '建立採購單' })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// 3. 選擇倉庫 (使用 SearchableSelect combobox)
|
||||||
|
const warehouseCombobox = page.locator('label:has-text("預計入庫倉庫")').locator('..').getByRole('combobox');
|
||||||
|
await warehouseCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '中央倉庫' }).click();
|
||||||
|
|
||||||
|
// 4. 選擇供應商
|
||||||
|
const supplierCombobox = page.locator('label:has-text("供應商")').locator('..').getByRole('combobox');
|
||||||
|
await supplierCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '台積電' }).click();
|
||||||
|
|
||||||
|
// 5. 填寫下單日期(應該已有預設值,但確保有值)
|
||||||
|
const orderDateInput = page.locator('label:has-text("下單日期")').locator('..').locator('input[type="date"]');
|
||||||
|
const currentDate = await orderDateInput.inputValue();
|
||||||
|
if (!currentDate) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
await orderDateInput.fill(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 填寫備註
|
||||||
|
await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 批量寫入驗證');
|
||||||
|
|
||||||
|
// 7. 新增第一個品項
|
||||||
|
await page.getByRole('button', { name: '新增一個品項' }).click();
|
||||||
|
|
||||||
|
// 選擇商品(第一行)
|
||||||
|
const firstRow = page.locator('table tbody tr').first();
|
||||||
|
const firstProductCombobox = firstRow.getByRole('combobox').first();
|
||||||
|
await firstProductCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '紅糖' }).click();
|
||||||
|
|
||||||
|
// 填寫數量
|
||||||
|
const firstQtyInput = firstRow.locator('input[type="number"]').first();
|
||||||
|
await firstQtyInput.clear();
|
||||||
|
await firstQtyInput.fill('5');
|
||||||
|
|
||||||
|
// 填寫小計(主要金額欄位)
|
||||||
|
const firstSubtotalInput = firstRow.locator('input[type="number"]').nth(1);
|
||||||
|
await firstSubtotalInput.fill('500');
|
||||||
|
|
||||||
|
// 8. 新增第二個品項(驗證批量寫入)
|
||||||
|
await page.getByRole('button', { name: '新增一個品項' }).click();
|
||||||
|
|
||||||
|
const secondRow = page.locator('table tbody tr').nth(1);
|
||||||
|
const secondProductCombobox = secondRow.getByRole('combobox').first();
|
||||||
|
await secondProductCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '粗吸管' }).click();
|
||||||
|
|
||||||
|
const secondQtyInput = secondRow.locator('input[type="number"]').first();
|
||||||
|
await secondQtyInput.clear();
|
||||||
|
await secondQtyInput.fill('10');
|
||||||
|
|
||||||
|
const secondSubtotalInput = secondRow.locator('input[type="number"]').nth(1);
|
||||||
|
await secondSubtotalInput.fill('200');
|
||||||
|
|
||||||
|
// 9. 點擊「確認發布採購單」
|
||||||
|
await page.getByRole('button', { name: '確認發布採購單' }).click();
|
||||||
|
|
||||||
|
// 10. 驗證結果 — 應跳轉回列表頁或顯示詳情頁
|
||||||
|
// Inertia.js 的 onSuccess 會觸發頁面導航
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ }))
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 11. 截圖留存
|
||||||
|
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||||
|
await page.screenshot({ path: 'e2e/screenshots/procurement-po-create-success.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能成功編輯採購單', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
// 1. 前往採購單列表
|
||||||
|
await page.goto('/purchase-orders');
|
||||||
|
await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible();
|
||||||
|
|
||||||
|
// 2. 找到並點擊第一個可編輯的採購單 (草稿或待審核狀態)
|
||||||
|
const editLink = page.locator('button[title="編輯"], a[title="編輯"]').first();
|
||||||
|
await expect(editLink).toBeVisible({ timeout: 10000 });
|
||||||
|
await editLink.click();
|
||||||
|
|
||||||
|
// 3. 驗證已進入編輯頁
|
||||||
|
await expect(page.getByRole('heading', { name: '編輯採購單' })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 4. 修改備註
|
||||||
|
await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 已被編輯過');
|
||||||
|
|
||||||
|
// 5. 點擊「更新採購單」
|
||||||
|
await page.getByRole('button', { name: '更新採購單' }).click();
|
||||||
|
|
||||||
|
// 6. 驗證結果 — 返回列表或詳情頁
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ }))
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 7. 截圖留存
|
||||||
|
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||||
|
await page.screenshot({ path: 'e2e/screenshots/procurement-po-edit-success.png', fullPage: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
22
e2e/production.spec.ts
Normal file
22
e2e/production.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('生產管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入配方管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/recipes');
|
||||||
|
await expect(page.getByRole('heading', { name: /配方管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入生產單管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/production-orders');
|
||||||
|
await expect(page.getByRole('heading', { name: /生產工單/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /建立生產單/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/products.spec.ts
Normal file
15
e2e/products.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('商品管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入商品列表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/products');
|
||||||
|
await expect(page.getByRole('heading', { name: /商品資料管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/sales.spec.ts
Normal file
15
e2e/sales.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('銷售管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip('應能進入銷貨匯入頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/sales/imports');
|
||||||
|
await expect(page.getByRole('heading', { name: /功能製作中/ })).toBeVisible();
|
||||||
|
// await expect(page.locator('table')).toBeVisible();
|
||||||
|
// await expect(page.getByRole('button', { name: /匯入/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/vendors.spec.ts
Normal file
15
e2e/vendors.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('供應商管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入供應商列表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/vendors');
|
||||||
|
await expect(page.getByRole('heading', { name: /廠商資料管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
14
e2e/warehouses.spec.ts
Normal file
14
e2e/warehouses.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('倉庫管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入倉庫列表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/warehouses');
|
||||||
|
await expect(page.getByRole('heading', { name: /倉庫管理/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增倉庫/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
767
package-lock.json
generated
767
package-lock.json
generated
@@ -33,16 +33,19 @@
|
|||||||
"jsbarcode": "^3.12.1",
|
"jsbarcode": "^3.12.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
@@ -83,6 +86,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -318,6 +322,16 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
@@ -772,6 +786,471 @@
|
|||||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inertiajs/core": {
|
"node_modules/@inertiajs/core": {
|
||||||
"version": "2.3.4",
|
"version": "2.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.4.tgz",
|
||||||
@@ -846,6 +1325,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -2847,6 +3342,7 @@
|
|||||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -2856,6 +3352,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2866,6 +3363,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -3001,6 +3499,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -3269,6 +3768,12 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -3285,7 +3790,8 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
@@ -3476,7 +3982,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3949,6 +4454,33 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/image-size": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queue": "6.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"image-size": "bin/image-size.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/immer": {
|
"node_modules/immer": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
@@ -3959,6 +4491,12 @@
|
|||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/inline-style-parser": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||||
@@ -4040,6 +4578,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -4086,6 +4630,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/laravel-precognition": {
|
"node_modules/laravel-precognition": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.0.tgz",
|
||||||
@@ -4116,6 +4672,15 @@
|
|||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
@@ -5343,6 +5908,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parse-entities": {
|
"node_modules/parse-entities": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
@@ -5379,6 +5950,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5386,6 +5958,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -5427,6 +6046,39 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pptxgenjs": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^22.8.1",
|
||||||
|
"https": "^1.0.0",
|
||||||
|
"image-size": "^1.2.1",
|
||||||
|
"jszip": "^3.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pptxgenjs/node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pptxgenjs/node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/property-information": {
|
"node_modules/property-information": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||||
@@ -5458,11 +6110,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -5475,6 +6137,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -5539,6 +6202,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@@ -5635,6 +6299,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
@@ -5669,7 +6348,8 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -5813,6 +6493,12 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -5831,6 +6517,68 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sharp/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.3",
|
"version": "1.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
@@ -5945,6 +6693,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -6035,7 +6792,8 @@
|
|||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
@@ -6360,6 +7118,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
47
playwright.config.ts
Normal file
47
playwright.config.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright E2E 測試設定檔
|
||||||
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
|
||||||
|
/* 平行執行測試 */
|
||||||
|
fullyParallel: true,
|
||||||
|
|
||||||
|
/* CI 環境下禁止 test.only */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
||||||
|
/* CI 環境下失敗重試 2 次 */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
|
/* CI 環境下單 worker */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
|
||||||
|
/* 使用 HTML 報告 */
|
||||||
|
reporter: 'html',
|
||||||
|
|
||||||
|
/* 全域共用設定 */
|
||||||
|
use: {
|
||||||
|
/* 本機開發伺服器位址 */
|
||||||
|
baseURL: 'http://localhost:8081',
|
||||||
|
|
||||||
|
/* 失敗時自動截圖 */
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
|
/* 失敗時保留錄影 */
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
|
||||||
|
/* 失敗重試時收集 trace */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* 只使用 Chromium 進行測試(開發階段足夠) */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Eye, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Link, useForm } from "@inertiajs/react";
|
import { Link, useForm } from "@inertiajs/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -62,6 +62,22 @@ export default function GoodsReceiptActions({
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 草稿或退回狀態才可編輯 */}
|
||||||
|
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
|
||||||
|
<Can permission="goods_receipts.edit">
|
||||||
|
<Link href={route('goods-receipts.edit', receipt.id)}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
title="編輯"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Can>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 只允許刪除草稿或已退回的進貨單 */}
|
{/* 只允許刪除草稿或已退回的進貨單 */}
|
||||||
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
|
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
|
||||||
<Can permission="goods_receipts.delete">
|
<Can permission="goods_receipts.delete">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 生產工單完工入庫 - 選擇倉庫彈窗
|
* 生產工單完工入庫 - 選擇倉庫彈窗
|
||||||
|
* 含產出確認與耗損記錄功能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button";
|
|||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react";
|
import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react";
|
||||||
|
import { formatQuantity } from "@/lib/utils";
|
||||||
|
|
||||||
interface Warehouse {
|
interface Warehouse {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps {
|
|||||||
warehouseId: number;
|
warehouseId: number;
|
||||||
batchNumber: string;
|
batchNumber: string;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
|
actualOutputQuantity: number;
|
||||||
|
lossReason: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
warehouses: Warehouse[];
|
warehouses: Warehouse[];
|
||||||
processing?: boolean;
|
processing?: boolean;
|
||||||
// 新增商品資訊以利產生批號
|
// 商品資訊用於產生批號
|
||||||
productCode?: string;
|
productCode?: string;
|
||||||
productId?: number;
|
productId?: number;
|
||||||
|
// 預計產量(用於耗損計算)
|
||||||
|
outputQuantity: number;
|
||||||
|
// 成品單位名稱
|
||||||
|
unitName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WarehouseSelectionModal({
|
export default function WarehouseSelectionModal({
|
||||||
@@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({
|
|||||||
processing = false,
|
processing = false,
|
||||||
productCode,
|
productCode,
|
||||||
productId,
|
productId,
|
||||||
|
outputQuantity,
|
||||||
|
unitName = '',
|
||||||
}: WarehouseSelectionModalProps) {
|
}: WarehouseSelectionModalProps) {
|
||||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||||
const [batchNumber, setBatchNumber] = React.useState<string>("");
|
const [batchNumber, setBatchNumber] = React.useState<string>("");
|
||||||
const [expiryDate, setExpiryDate] = React.useState<string>("");
|
const [expiryDate, setExpiryDate] = React.useState<string>("");
|
||||||
|
const [actualOutputQuantity, setActualOutputQuantity] = React.useState<string>("");
|
||||||
|
const [lossReason, setLossReason] = React.useState<string>("");
|
||||||
|
|
||||||
|
// 當開啟時,初始化實際產出數量為預計產量
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setActualOutputQuantity(String(outputQuantity));
|
||||||
|
setLossReason("");
|
||||||
|
}
|
||||||
|
}, [isOpen, outputQuantity]);
|
||||||
|
|
||||||
// 當開啟時,嘗試產生成品批號 (若有資訊)
|
// 當開啟時,嘗試產生成品批號 (若有資訊)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({
|
|||||||
}
|
}
|
||||||
}, [isOpen, productCode, productId]);
|
}, [isOpen, productCode, productId]);
|
||||||
|
|
||||||
|
// 計算耗損數量
|
||||||
|
const actualQty = parseFloat(actualOutputQuantity) || 0;
|
||||||
|
const lossQuantity = outputQuantity - actualQty;
|
||||||
|
const hasLoss = lossQuantity > 0;
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (selectedId && batchNumber) {
|
if (selectedId && batchNumber && actualQty > 0) {
|
||||||
onConfirm({
|
onConfirm({
|
||||||
warehouseId: selectedId,
|
warehouseId: selectedId,
|
||||||
batchNumber,
|
batchNumber,
|
||||||
expiryDate
|
expiryDate,
|
||||||
|
actualOutputQuantity: actualQty,
|
||||||
|
lossReason: hasLoss ? lossReason : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 驗證:實際產出不可大於預計產量,也不可小於等於 0
|
||||||
|
const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-primary-main">
|
<DialogTitle className="flex items-center gap-2 text-primary-main">
|
||||||
<WarehouseIcon className="h-5 w-5" />
|
<WarehouseIcon className="h-5 w-5" />
|
||||||
選擇完工入庫倉庫
|
完工入庫確認
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-6 space-y-6">
|
<div className="py-6 space-y-6">
|
||||||
|
{/* 倉庫選擇 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||||
<WarehouseIcon className="h-3 w-3" />
|
<WarehouseIcon className="h-3 w-3" />
|
||||||
@@ -96,6 +127,7 @@ export default function WarehouseSelectionModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 成品批號 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||||
<Tag className="h-3 w-3" />
|
<Tag className="h-3 w-3" />
|
||||||
@@ -109,6 +141,7 @@ export default function WarehouseSelectionModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 成品效期 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||||
<CalendarIcon className="h-3 w-3" />
|
<CalendarIcon className="h-3 w-3" />
|
||||||
@@ -121,6 +154,64 @@ export default function WarehouseSelectionModal({
|
|||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔線 - 產出確認區 */}
|
||||||
|
<div className="border-t border-grey-4 pt-4">
|
||||||
|
<p className="text-xs font-bold text-grey-2 uppercase tracking-wider mb-4">產出確認</p>
|
||||||
|
|
||||||
|
{/* 預計產量(唯讀) */}
|
||||||
|
<div className="flex items-center justify-between mb-3 px-3 py-2 bg-grey-5 rounded-lg border border-grey-4">
|
||||||
|
<span className="text-sm text-grey-2">預計產量</span>
|
||||||
|
<span className="font-bold text-grey-0">
|
||||||
|
{formatQuantity(outputQuantity)} {unitName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 實際產出數量 */}
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">
|
||||||
|
實際產出數量 *
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
max={outputQuantity}
|
||||||
|
value={actualOutputQuantity}
|
||||||
|
onChange={(e) => setActualOutputQuantity(e.target.value)}
|
||||||
|
className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`}
|
||||||
|
/>
|
||||||
|
{unitName && <span className="text-sm text-grey-2 whitespace-nowrap">{unitName}</span>}
|
||||||
|
</div>
|
||||||
|
{actualQty > outputQuantity && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">實際產出不可超過預計產量</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 耗損顯示 */}
|
||||||
|
{hasLoss && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 space-y-2 animate-in fade-in duration-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-bold text-orange-700">
|
||||||
|
耗損數量:{formatQuantity(lossQuantity)} {unitName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-orange-600">
|
||||||
|
耗損原因 (選填)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={lossReason}
|
||||||
|
onChange={(e) => setLossReason(e.target.value)}
|
||||||
|
placeholder="例如:製作過程損耗、品質不合格..."
|
||||||
|
className="h-9 border-orange-200 focus:ring-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -134,7 +225,7 @@ export default function WarehouseSelectionModal({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!selectedId || !batchNumber || processing}
|
disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
|
||||||
className="gap-2 button-filled-primary"
|
className="gap-2 button-filled-primary"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
|||||||
304
resources/js/Components/UtilityFee/AttachmentDialog.tsx
Normal file
304
resources/js/Components/UtilityFee/AttachmentDialog.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/Components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/Components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Upload
|
||||||
|
} from "lucide-react";
|
||||||
|
import { UtilityFee } from "./UtilityFeeDialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
original_name: string;
|
||||||
|
mime_type: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
fee: UtilityFee | null;
|
||||||
|
onAttachmentsChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttachmentDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
fee,
|
||||||
|
onAttachmentsChange,
|
||||||
|
}: Props) {
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && fee) {
|
||||||
|
fetchAttachments();
|
||||||
|
} else {
|
||||||
|
setAttachments([]);
|
||||||
|
}
|
||||||
|
}, [open, fee]);
|
||||||
|
|
||||||
|
const fetchAttachments = async () => {
|
||||||
|
if (!fee) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(route("utility-fees.attachments", fee.id));
|
||||||
|
setAttachments(response.data.attachments || []);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("取得附件失敗");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0 || !fee) return;
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
// 驗證檔案大小 (2MB)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast.error("檔案大小不能超過 2MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證檔案類型
|
||||||
|
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
toast.error("僅支援 JPG, PNG, WebP 圖片及 PDF 文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證數量限制 (3張)
|
||||||
|
if (attachments.length >= 3) {
|
||||||
|
toast.error("最多僅可上傳 3 個附件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await axios.post(route("utility-fees.upload-attachment", fee.id), formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
toast.success("上傳成功");
|
||||||
|
fetchAttachments();
|
||||||
|
onAttachmentsChange?.();
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = error.response?.data?.message || Object.values(error.response?.data?.errors || {})[0] || "上傳失敗";
|
||||||
|
toast.error(errorMsg as string);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
// 清空 input 讓同一個檔案可以重複觸發 onChange
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (id: number) => {
|
||||||
|
setDeleteId(id);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId || !fee) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await axios.delete(route("utility-fees.delete-attachment", [fee.id, deleteId]));
|
||||||
|
toast.success("附件已刪除");
|
||||||
|
setAttachments(attachments.filter((a) => a.id !== deleteId));
|
||||||
|
onAttachmentsChange?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("刪除失敗");
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setDeleteId(null);
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[550px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
|
<FileText className="h-6 w-6 text-primary-main" />
|
||||||
|
附件管理
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
管理 {fee?.billing_month} {fee?.category_name} 的支援文件(對帳單、收據等)。
|
||||||
|
最多可上傳 3 個檔案,單一檔案上限 2MB。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-6">
|
||||||
|
{/* 上傳區塊 */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-dashed border-slate-300">
|
||||||
|
<div className="text-sm text-slate-500">
|
||||||
|
{attachments.length < 3
|
||||||
|
? `還可以上傳 ${3 - attachments.length} 個附件`
|
||||||
|
: "已達到上傳數量上限"}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading || attachments.length >= 3}
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.pdf"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
disabled={uploading || attachments.length >= 3}
|
||||||
|
className="button-filled-primary"
|
||||||
|
>
|
||||||
|
<label htmlFor="file-upload" className="cursor-pointer flex items-center">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
選擇檔案
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 附件列表 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mb-2" />
|
||||||
|
<p>載入中...</p>
|
||||||
|
</div>
|
||||||
|
) : attachments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-slate-50 rounded-xl border border-slate-100">
|
||||||
|
<p className="text-slate-400">目前尚無附件</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{attachments.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="group relative flex items-center justify-between p-3 bg-white rounded-xl border border-slate-200 hover:border-primary-light transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<div className="h-12 w-12 rounded-lg bg-slate-100 flex items-center justify-center overflow-hidden shrink-0 border border-slate-100">
|
||||||
|
{file.mime_type.startsWith("image/") ? (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.original_name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = "";
|
||||||
|
(e.target as HTMLImageElement).className = "hidden";
|
||||||
|
(e.target as HTMLImageElement).parentElement?.classList.add("bg-slate-200");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileText className="h-6 w-6 text-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate pr-2" title={file.original_name}>
|
||||||
|
{file.original_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary h-8 w-8 p-0"
|
||||||
|
asChild
|
||||||
|
title="另開分頁"
|
||||||
|
>
|
||||||
|
<a href={file.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-error h-8 w-8 p-0"
|
||||||
|
onClick={() => confirmDelete(file.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
title="刪除附件"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>確認刪除附件?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
這將永久刪除此附件,此操作無法撤銷。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
確認刪除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,11 +20,19 @@ import { validateInvoiceNumber } from "@/utils/validation";
|
|||||||
|
|
||||||
export interface UtilityFee {
|
export interface UtilityFee {
|
||||||
id: number;
|
id: number;
|
||||||
transaction_date: string;
|
billing_month: string;
|
||||||
category: string;
|
category_id: number;
|
||||||
|
category?: string; // 相容於舊版或特定視圖
|
||||||
|
category_name?: string;
|
||||||
amount: number | string;
|
amount: number | string;
|
||||||
|
status: string;
|
||||||
|
payment_status?: 'pending' | 'paid' | 'overdue'; // 相容於舊版
|
||||||
|
due_date: string;
|
||||||
|
payment_date?: string;
|
||||||
|
transaction_date?: string; // 相容於舊版
|
||||||
invoice_number?: string;
|
invoice_number?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
attachments_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -53,7 +61,8 @@ export default function UtilityFeeDialog({
|
|||||||
availableCategories,
|
availableCategories,
|
||||||
}: UtilityFeeDialogProps) {
|
}: UtilityFeeDialogProps) {
|
||||||
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
||||||
transaction_date: getCurrentDate(),
|
transaction_date: "",
|
||||||
|
due_date: getCurrentDate(),
|
||||||
category: "",
|
category: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
invoice_number: "",
|
invoice_number: "",
|
||||||
@@ -68,7 +77,8 @@ export default function UtilityFeeDialog({
|
|||||||
clearErrors();
|
clearErrors();
|
||||||
if (fee) {
|
if (fee) {
|
||||||
setData({
|
setData({
|
||||||
transaction_date: fee.transaction_date,
|
transaction_date: fee.transaction_date || "",
|
||||||
|
due_date: fee.due_date,
|
||||||
category: fee.category,
|
category: fee.category,
|
||||||
amount: fee.amount.toString(),
|
amount: fee.amount.toString(),
|
||||||
invoice_number: fee.invoice_number || "",
|
invoice_number: fee.invoice_number || "",
|
||||||
@@ -76,7 +86,14 @@ export default function UtilityFeeDialog({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
reset();
|
reset();
|
||||||
setData("transaction_date", getCurrentDate());
|
setData({
|
||||||
|
transaction_date: "",
|
||||||
|
due_date: getCurrentDate(),
|
||||||
|
category: "",
|
||||||
|
amount: "",
|
||||||
|
invoice_number: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, fee]);
|
}, [open, fee]);
|
||||||
@@ -131,9 +148,28 @@ export default function UtilityFeeDialog({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="due_date">
|
||||||
|
繳費期限 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
value={data.due_date}
|
||||||
|
onChange={(e) => setData("due_date", e.target.value)}
|
||||||
|
className={`pl-9 block w-full ${errors.due_date ? "border-red-500" : ""}`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.due_date && <p className="text-sm text-red-500">{errors.due_date}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="transaction_date">
|
<Label htmlFor="transaction_date">
|
||||||
費用日期 <span className="text-red-500">*</span>
|
繳費日期
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||||
@@ -143,11 +179,11 @@ export default function UtilityFeeDialog({
|
|||||||
value={data.transaction_date}
|
value={data.transaction_date}
|
||||||
onChange={(e) => setData("transaction_date", e.target.value)}
|
onChange={(e) => setData("transaction_date", e.target.value)}
|
||||||
className={`pl-9 block w-full ${errors.transaction_date ? "border-red-500" : ""}`}
|
className={`pl-9 block w-full ${errors.transaction_date ? "border-red-500" : ""}`}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.transaction_date && <p className="text-sm text-red-500">{errors.transaction_date}</p>}
|
{errors.transaction_date && <p className="text-sm text-red-500">{errors.transaction_date}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -266,14 +266,13 @@ export default function WarehouseDialog({
|
|||||||
{/* 倉庫地址 */}
|
{/* 倉庫地址 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="address">
|
<Label htmlFor="address">
|
||||||
倉庫地址 <span className="text-red-500">*</span>
|
倉庫地址
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="address"
|
id="address"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
placeholder="例:台北市信義區信義路五段7號"
|
placeholder="例:台北市信義區信義路五段7號"
|
||||||
required
|
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,22 +27,33 @@ export default function Pagination({ links, className }: PaginationProps) {
|
|||||||
const isNext = label === "Next";
|
const isNext = label === "Next";
|
||||||
const activeIndex = links.findIndex(l => l.active);
|
const activeIndex = links.findIndex(l => l.active);
|
||||||
|
|
||||||
// Tablet/Mobile visibility logic (< md):
|
// Responsive visibility logic:
|
||||||
// Show: Previous, Next, Active, and up to 2 neighbors (Total ~5 numeric pages)
|
// Global: Previous, Next, Active are always visible
|
||||||
// Hide others on small screens (hidden md:flex)
|
// Mobile (< sm): Active, +-1, First, Last, and Ellipses
|
||||||
// User requested: "small than 800... display 5 pages"
|
// Tablet (sm < md): Active, +-2, First, Last, and Ellipses
|
||||||
const isVisibleOnTablet =
|
// Desktop (>= md): All standard pages
|
||||||
|
const isFirst = key === 1;
|
||||||
|
const isLast = key === links.length - 2;
|
||||||
|
const isEllipsis = !isPrevious && !isNext && !link.url;
|
||||||
|
|
||||||
|
const isMobileVisible =
|
||||||
isPrevious ||
|
isPrevious ||
|
||||||
isNext ||
|
isNext ||
|
||||||
link.active ||
|
link.active ||
|
||||||
|
isFirst ||
|
||||||
|
isLast ||
|
||||||
|
isEllipsis ||
|
||||||
key === activeIndex - 1 ||
|
key === activeIndex - 1 ||
|
||||||
key === activeIndex + 1 ||
|
key === activeIndex + 1;
|
||||||
|
|
||||||
|
const isTabletVisible =
|
||||||
|
isMobileVisible ||
|
||||||
key === activeIndex - 2 ||
|
key === activeIndex - 2 ||
|
||||||
key === activeIndex + 2;
|
key === activeIndex + 2;
|
||||||
|
|
||||||
const baseClasses = cn(
|
const baseClasses = cn(
|
||||||
isVisibleOnTablet ? "flex" : "hidden md:flex",
|
"h-9 items-center justify-center rounded-md border px-3 text-sm",
|
||||||
"h-9 items-center justify-center rounded-md border px-3 text-sm"
|
isMobileVisible ? "flex" : (isTabletVisible ? "hidden sm:flex md:flex" : "hidden md:flex")
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果是 Previous/Next 但沒有 URL,則不渲染(或者渲染為 disabled)
|
// 如果是 Previous/Next 但沒有 URL,則不渲染(或者渲染為 disabled)
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface SearchableSelectProps {
|
|||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
/** 是否可清除選取 */
|
/** 是否可清除選取 */
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
|
/** 是否為無效狀態(顯示紅色邊框) */
|
||||||
|
"aria-invalid"?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchableSelect({
|
export function SearchableSelect({
|
||||||
@@ -52,6 +54,7 @@ export function SearchableSelect({
|
|||||||
searchThreshold = 10,
|
searchThreshold = 10,
|
||||||
showSearch,
|
showSearch,
|
||||||
isClearable = false,
|
isClearable = false,
|
||||||
|
"aria-invalid": ariaInvalid,
|
||||||
}: SearchableSelectProps) {
|
}: SearchableSelectProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -79,12 +82,15 @@ export function SearchableSelect({
|
|||||||
!selectedOption && "text-grey-3",
|
!selectedOption && "text-grey-3",
|
||||||
// Focus state - primary border with ring
|
// Focus state - primary border with ring
|
||||||
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
|
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
|
||||||
|
// Error state
|
||||||
|
ariaInvalid && "border-destructive ring-destructive/20",
|
||||||
// Disabled state
|
// Disabled state
|
||||||
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
// Height
|
// Height
|
||||||
"h-9",
|
"h-9",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-invalid={ariaInvalid}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedOption ? selectedOption.label : placeholder}
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
@@ -124,7 +130,7 @@ export function SearchableSelect({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer group"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -137,7 +143,7 @@ export function SearchableSelect({
|
|||||||
<div className="flex items-center justify-between flex-1">
|
<div className="flex items-center justify-between flex-1">
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
{option.sublabel && (
|
{option.sublabel && (
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
<span className="text-xs text-muted-foreground ml-2 group-data-[selected=true]:text-white">
|
||||||
{option.sublabel}
|
{option.sublabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Filter,
|
Filter,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Package,
|
Wallet,
|
||||||
Pocket,
|
Pocket,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
FileText
|
FileText
|
||||||
@@ -29,6 +29,7 @@ import Pagination from "@/Components/shared/Pagination";
|
|||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import { Can } from "@/Components/Permission/Can";
|
import { Can } from "@/Components/Permission/Can";
|
||||||
import { Checkbox } from "@/Components/ui/checkbox";
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
|
|
||||||
interface Record {
|
interface Record {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,8 +38,14 @@ interface Record {
|
|||||||
category: string;
|
category: string;
|
||||||
item: string;
|
item: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
invoice_number?: string;
|
invoice_date?: string | null;
|
||||||
|
invoice_number?: string | null;
|
||||||
amount: number | string;
|
amount: number | string;
|
||||||
|
tax_amount: number | string;
|
||||||
|
status?: string;
|
||||||
|
payment_method?: string | null;
|
||||||
|
payment_note?: string | null;
|
||||||
|
remarks?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -52,7 +59,7 @@ interface PageProps {
|
|||||||
};
|
};
|
||||||
summary: {
|
summary: {
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
purchase_total: number;
|
payable_total: number;
|
||||||
utility_total: number;
|
utility_total: number;
|
||||||
record_count: number;
|
record_count: number;
|
||||||
};
|
};
|
||||||
@@ -273,10 +280,10 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-orange-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-orange-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||||
<Package className="h-6 w-6 text-orange-500 shrink-0" />
|
<Wallet className="h-6 w-6 text-orange-500 shrink-0" />
|
||||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||||
<span className="text-sm text-gray-500 font-medium shrink-0">採購支出</span>
|
<span className="text-sm text-gray-500 font-medium shrink-0">應付帳款</span>
|
||||||
<span className="text-xl font-bold text-gray-900 truncate">$ {Number(summary.purchase_total).toLocaleString()}</span>
|
<span className="text-xl font-bold text-gray-900 truncate">$ {Number(summary.payable_total).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -305,13 +312,16 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
|||||||
<TableHead className="w-[120px] text-center">來源</TableHead>
|
<TableHead className="w-[120px] text-center">來源</TableHead>
|
||||||
<TableHead className="w-[140px] text-center">類別</TableHead>
|
<TableHead className="w-[140px] text-center">類別</TableHead>
|
||||||
<TableHead className="px-6">項目詳細</TableHead>
|
<TableHead className="px-6">項目詳細</TableHead>
|
||||||
<TableHead className="w-[180px] text-right px-6">金額</TableHead>
|
<TableHead className="w-[160px] text-center">付款方式 / 備註</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">狀態</TableHead>
|
||||||
|
<TableHead className="w-[120px] text-right">稅額</TableHead>
|
||||||
|
<TableHead className="w-[150px] text-right px-6">總金額</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{records.data.length === 0 ? (
|
{records.data.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6}>
|
<TableCell colSpan={7}>
|
||||||
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
|
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
|
||||||
<FileText className="h-10 w-10 opacity-20" />
|
<FileText className="h-10 w-10 opacity-20" />
|
||||||
<p>此日期區間內無支出紀錄</p>
|
<p>此日期區間內無支出紀錄</p>
|
||||||
@@ -333,7 +343,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge variant="secondary" className={
|
<Badge variant="secondary" className={
|
||||||
record.source === '採購單'
|
record.source === '應付帳款'
|
||||||
? 'bg-orange-50 text-orange-700 border-orange-100'
|
? 'bg-orange-50 text-orange-700 border-orange-100'
|
||||||
: 'bg-blue-50 text-blue-700 border-blue-100'
|
: 'bg-blue-50 text-blue-700 border-blue-100'
|
||||||
}>
|
}>
|
||||||
@@ -348,11 +358,47 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-gray-900">{record.item}</span>
|
<span className="font-medium text-gray-900">{record.item}</span>
|
||||||
{record.invoice_number && (
|
{(record.invoice_number || record.invoice_date) && (
|
||||||
<span className="text-xs text-gray-400">發票:{record.invoice_number}</span>
|
<span className="text-xs text-gray-400 mt-0.5">
|
||||||
|
發票:{record.invoice_number || '-'}
|
||||||
|
{record.invoice_date && ` (${record.invoice_date})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{record.remarks && (
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 truncate max-w-[200px]" title={record.remarks}>
|
||||||
|
備註:{record.remarks}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-sm text-gray-700">{record.payment_method || '-'}</span>
|
||||||
|
{record.payment_note && (
|
||||||
|
<span className="text-xs text-gray-400 truncate max-w-[120px]" title={record.payment_note}>
|
||||||
|
{record.payment_note}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{record.status === 'paid' ? (
|
||||||
|
<StatusBadge variant="success">已付款</StatusBadge>
|
||||||
|
) : record.status === 'pending' ? (
|
||||||
|
<StatusBadge variant="warning">待付款</StatusBadge>
|
||||||
|
) : record.status === 'overdue' ? (
|
||||||
|
<StatusBadge variant="destructive">已逾期</StatusBadge>
|
||||||
|
) : record.status === 'draft' ? (
|
||||||
|
<StatusBadge variant="neutral">草稿</StatusBadge>
|
||||||
|
) : record.status === 'approved' ? (
|
||||||
|
<StatusBadge variant="info">已核准</StatusBadge>
|
||||||
|
) : (
|
||||||
|
<StatusBadge variant="neutral">{record.status || '-'}</StatusBadge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600">
|
||||||
|
{record.tax_amount ? `$ ${Number(record.tax_amount).toLocaleString()}` : '-'}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right font-bold text-gray-900 px-4">
|
<TableCell className="text-right font-bold text-gray-900 px-4">
|
||||||
$ {Number(record.amount).toLocaleString()}
|
$ {Number(record.amount).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -318,10 +318,10 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
|||||||
from={activities.from}
|
from={activities.from}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-4 w-full sm:w-auto">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<span>每頁顯示</span>
|
<span className="shrink-0">每頁顯示</span>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={perPage}
|
value={perPage}
|
||||||
onValueChange={handlePerPageChange}
|
onValueChange={handlePerPageChange}
|
||||||
@@ -334,11 +334,11 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
|||||||
className="w-[100px] h-8"
|
className="w-[100px] h-8"
|
||||||
showSearch={false}
|
showSearch={false}
|
||||||
/>
|
/>
|
||||||
<span>筆</span>
|
<span className="shrink-0">筆</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">共 {activities.total} 筆資料</span>
|
<span className="text-sm text-gray-500 whitespace-nowrap">共 {activities.total} 筆資料</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full md:w-auto flex justify-center md:justify-end">
|
<div className="w-full sm:w-auto flex justify-center sm:justify-end shrink-0">
|
||||||
<Pagination links={activities.links} />
|
<Pagination links={activities.links} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, useForm } from "@inertiajs/react";
|
import { Head, useForm, router } from "@inertiajs/react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
Bell,
|
||||||
Save,
|
Save,
|
||||||
Settings
|
Settings,
|
||||||
|
Send
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ interface PageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingIndex({ settings }: PageProps) {
|
export default function SettingIndex({ settings }: PageProps) {
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const { data, setData, post, processing } = useForm({
|
const { data, setData, post, processing } = useForm({
|
||||||
settings: Object.values(settings).flat().map(s => ({
|
settings: Object.values(settings).flat().map(s => ({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
@@ -40,6 +43,8 @@ export default function SettingIndex({ settings }: PageProps) {
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState("finance");
|
||||||
|
|
||||||
const handleValueChange = (key: string, value: string) => {
|
const handleValueChange = (key: string, value: string) => {
|
||||||
const newSettings = data.settings.map(s =>
|
const newSettings = data.settings.map(s =>
|
||||||
s.key === key ? { ...s, value } : s
|
s.key === key ? { ...s, value } : s
|
||||||
@@ -54,6 +59,14 @@ export default function SettingIndex({ settings }: PageProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTestNotification = () => {
|
||||||
|
setIsTesting(true);
|
||||||
|
router.post(route('settings.test-notification'), { settings: data.settings }, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => setIsTesting(false)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const renderSettingRow = (setting: Setting) => {
|
const renderSettingRow = (setting: Setting) => {
|
||||||
const currentVal = data.settings.find(s => s.key === setting.key)?.value || '';
|
const currentVal = data.settings.find(s => s.key === setting.key)?.value || '';
|
||||||
|
|
||||||
@@ -65,11 +78,14 @@ export default function SettingIndex({ settings }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={setting.key.includes('password') ? 'password' : 'text'}
|
||||||
value={currentVal}
|
value={currentVal}
|
||||||
onChange={(e) => handleValueChange(setting.key, e.target.value)}
|
onChange={(e) => handleValueChange(setting.key, e.target.value)}
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
/>
|
/>
|
||||||
|
{setting.key === 'notification.utility_fee_recipient_emails' && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1 mt-2">請以半形逗點「,」分隔多個 Email,例如:a@test.com,b@test.com</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -96,7 +112,7 @@ export default function SettingIndex({ settings }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<Tabs defaultValue="finance" className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList className="bg-white border p-1 h-auto gap-2">
|
<TabsList className="bg-white border p-1 h-auto gap-2">
|
||||||
<TabsTrigger value="finance" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
|
<TabsTrigger value="finance" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
|
||||||
<Coins className="h-4 w-4" /> 財務設定
|
<Coins className="h-4 w-4" /> 財務設定
|
||||||
@@ -110,6 +126,9 @@ export default function SettingIndex({ settings }: PageProps) {
|
|||||||
<TabsTrigger value="display" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
|
<TabsTrigger value="display" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
|
||||||
<Monitor className="h-4 w-4" /> 顯示設定
|
<Monitor className="h-4 w-4" /> 顯示設定
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="notification" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
|
||||||
|
<Bell className="h-4 w-4" /> 通知設定
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="finance">
|
<TabsContent value="finance">
|
||||||
@@ -160,7 +179,31 @@ export default function SettingIndex({ settings }: PageProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notification">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>通知設定</CardTitle>
|
||||||
|
<CardDescription>管理系統發送 Email 提醒信函(如:公共事業費逾期通知)的寄件帳號與預設收件群組。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1">
|
||||||
|
{settings.notification?.map(renderSettingRow)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 mt-6">
|
<div className="flex justify-end gap-4 mt-6">
|
||||||
|
{activeTab === 'notification' && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
onClick={handleTestNotification}
|
||||||
|
disabled={isTesting || processing}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
{isTesting ? "發送中..." : "發送測試信"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button-filled-primary"
|
className="button-filled-primary"
|
||||||
@@ -176,3 +219,4 @@ export default function SettingIndex({ settings }: PageProps) {
|
|||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
import { Head, useForm, Link } from '@inertiajs/react';
|
import { Head, useForm, Link, router } from '@inertiajs/react';
|
||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Input } from '@/Components/ui/input';
|
import { Input } from '@/Components/ui/input';
|
||||||
import { Label } from '@/Components/ui/label';
|
import { Label } from '@/Components/ui/label';
|
||||||
@@ -25,7 +25,7 @@ import { StatusBadge } from "@/Components/shared/StatusBadge";
|
|||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
Calendar as CalendarIcon,
|
Calendar as CalendarIcon,
|
||||||
Save,
|
Save,
|
||||||
@@ -52,7 +52,7 @@ interface PendingPOItem {
|
|||||||
received_quantity: number;
|
received_quantity: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
batchMode?: 'existing' | 'new';
|
batchMode?: 'existing' | 'new' | 'none';
|
||||||
originCountry?: string; // For new batch generation
|
originCountry?: string; // For new batch generation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,66 +75,112 @@ interface Vendor {
|
|||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 編輯模式的進貨單資料
|
||||||
|
interface ReceiptData {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
warehouse_id: number;
|
||||||
|
vendor_id: number | null;
|
||||||
|
vendor: Vendor | null;
|
||||||
|
purchase_order_id: number | null;
|
||||||
|
purchase_order?: any;
|
||||||
|
received_date: string;
|
||||||
|
remarks: string;
|
||||||
|
items: any[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
warehouses: { id: number; name: string; type: string }[];
|
warehouses: { id: number; name: string; type: string }[];
|
||||||
pendingPurchaseOrders: PendingPO[];
|
pendingPurchaseOrders: PendingPO[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
|
receipt?: ReceiptData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors }: Props) {
|
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors, receipt }: Props) {
|
||||||
|
const isEditMode = !!receipt;
|
||||||
const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
|
const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
|
||||||
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
|
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
|
|
||||||
// Manual Product Search States
|
|
||||||
const [productSearch, setProductSearch] = useState('');
|
// 全商品清單(用於雜項入庫/其他類型的 SearchableSelect)
|
||||||
const [foundProducts, setFoundProducts] = useState<any[]>([]);
|
const [allProducts, setAllProducts] = useState<any[]>([]);
|
||||||
|
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||||
|
|
||||||
// Duplicate Check States
|
// Duplicate Check States
|
||||||
const [warningOpen, setWarningOpen] = useState(false);
|
const [warningOpen, setWarningOpen] = useState(false);
|
||||||
const [warnings, setWarnings] = useState<any[]>([]);
|
const [warnings, setWarnings] = useState<any[]>([]);
|
||||||
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
|
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
|
||||||
|
|
||||||
const { data, setData, post, processing, errors } = useForm({
|
const { data, setData, processing, errors } = useForm({
|
||||||
type: 'standard', // 'standard', 'miscellaneous', 'other'
|
type: receipt?.type || 'standard',
|
||||||
warehouse_id: '',
|
warehouse_id: receipt?.warehouse_id?.toString() || '',
|
||||||
purchase_order_id: '',
|
purchase_order_id: receipt?.purchase_order_id?.toString() || '',
|
||||||
vendor_id: '',
|
vendor_id: receipt?.vendor_id?.toString() || '',
|
||||||
received_date: new Date().toISOString().split('T')[0],
|
received_date: receipt?.received_date || new Date().toISOString().split('T')[0],
|
||||||
remarks: '',
|
remarks: receipt?.remarks || '',
|
||||||
items: [] as any[],
|
items: (receipt?.items || []) as any[],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 搜尋商品 API(用於雜項入庫/其他類型)
|
// 編輯模式下初始化 vendor 與 PO 狀態
|
||||||
const searchProducts = async () => {
|
useEffect(() => {
|
||||||
if (!productSearch) return;
|
if (isEditMode) {
|
||||||
setIsSearching(true);
|
if (receipt?.vendor) {
|
||||||
|
setSelectedVendor(receipt.vendor);
|
||||||
|
}
|
||||||
|
if (receipt?.purchase_order) {
|
||||||
|
setSelectedPO(receipt.purchase_order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 判斷是否為非標準採購類型
|
||||||
|
const isNonStandard = data.type !== 'standard';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 載入所有商品(用於雜項入庫/其他類型的 SearchableSelect)
|
||||||
|
const fetchAllProducts = async () => {
|
||||||
|
setIsLoadingProducts(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(route('goods-receipts.search-products'), {
|
const response = await axios.get(route('goods-receipts.search-products'), {
|
||||||
params: { query: productSearch },
|
params: { query: '*' },
|
||||||
});
|
});
|
||||||
setFoundProducts(response.data);
|
setAllProducts(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to search products', error);
|
console.error('Failed to load products', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false);
|
setIsLoadingProducts(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 當選擇非標準類型且已選供應商時,載入所有商品
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNonStandard && selectedVendor) {
|
||||||
|
fetchAllProducts();
|
||||||
|
}
|
||||||
|
}, [isNonStandard, selectedVendor]);
|
||||||
|
|
||||||
// 選擇採購單
|
// 選擇採購單
|
||||||
const handleSelectPO = (po: PendingPO) => {
|
const handleSelectPO = (po: any) => {
|
||||||
setSelectedPO(po);
|
setSelectedPO(po);
|
||||||
// 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
|
// 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
|
||||||
const pendingItems = po.items.map((item) => ({
|
const pendingItems = po.items.map((item: any) => ({
|
||||||
product_id: item.product_id,
|
product_id: item.productId,
|
||||||
purchase_order_item_id: item.id,
|
purchase_order_item_id: item.id,
|
||||||
product_name: item.product_name,
|
product_name: item.productName,
|
||||||
product_code: item.product_code,
|
product_code: item.product_code || '',
|
||||||
unit: item.unit,
|
unit: item.selectedUnit === 'large' ? item.large_unit_name : item.base_unit_name,
|
||||||
|
selectedUnit: item.selectedUnit,
|
||||||
|
base_unit_id: item.base_unit_id,
|
||||||
|
base_unit_name: item.base_unit_name,
|
||||||
|
large_unit_id: item.large_unit_id,
|
||||||
|
large_unit_name: item.large_unit_name,
|
||||||
|
conversion_rate: item.conversion_rate,
|
||||||
quantity_ordered: item.quantity,
|
quantity_ordered: item.quantity,
|
||||||
quantity_received_so_far: item.received_quantity,
|
quantity_received_so_far: item.received_quantity || 0,
|
||||||
quantity_received: item.remaining, // 預填剩餘量
|
quantity_received: (item.quantity - (item.received_quantity || 0)).toString(), // 預填剩餘量
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unitPrice,
|
||||||
batch_number: '',
|
batch_number: '',
|
||||||
batchMode: 'new',
|
batchMode: 'new',
|
||||||
originCountry: 'TW',
|
originCountry: 'TW',
|
||||||
@@ -144,7 +190,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
purchase_order_id: po.id.toString(),
|
purchase_order_id: po.id.toString(),
|
||||||
vendor_id: po.vendor_id.toString(),
|
vendor_id: po.supplierId.toString(),
|
||||||
warehouse_id: po.warehouse_id ? po.warehouse_id.toString() : prev.warehouse_id,
|
warehouse_id: po.warehouse_id ? po.warehouse_id.toString() : prev.warehouse_id,
|
||||||
items: pendingItems,
|
items: pendingItems,
|
||||||
}));
|
}));
|
||||||
@@ -159,21 +205,37 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProduct = (product: any) => {
|
|
||||||
|
const handleAddEmptyItem = () => {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
product_id: product.id,
|
product_id: '',
|
||||||
product_name: product.name,
|
product_name: '',
|
||||||
product_code: product.code,
|
product_code: '',
|
||||||
quantity_received: 0,
|
quantity_received: 0,
|
||||||
unit_price: product.price || 0,
|
unit_price: 0,
|
||||||
|
subtotal: 0,
|
||||||
batch_number: '',
|
batch_number: '',
|
||||||
batchMode: 'new',
|
batchMode: 'new',
|
||||||
originCountry: 'TW',
|
originCountry: 'TW',
|
||||||
expiry_date: '',
|
expiry_date: '',
|
||||||
};
|
};
|
||||||
setData('items', [...data.items, newItem]);
|
setData('items', [...data.items, newItem]);
|
||||||
setFoundProducts([]);
|
};
|
||||||
setProductSearch('');
|
|
||||||
|
// 選擇商品後填入該列(用於空白列的 SearchableSelect)
|
||||||
|
const handleSelectProduct = (index: number, productId: string) => {
|
||||||
|
const product = allProducts.find(p => p.id.toString() === productId);
|
||||||
|
if (product) {
|
||||||
|
const newItems = [...data.items];
|
||||||
|
newItems[index] = {
|
||||||
|
...newItems[index],
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
product_code: product.code,
|
||||||
|
unit_price: product.price || 0,
|
||||||
|
};
|
||||||
|
setData('items', newItems);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeItem = (index: number) => {
|
const removeItem = (index: number) => {
|
||||||
@@ -184,7 +246,26 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
|
|
||||||
const updateItem = (index: number, field: string, value: any) => {
|
const updateItem = (index: number, field: string, value: any) => {
|
||||||
const newItems = [...data.items];
|
const newItems = [...data.items];
|
||||||
newItems[index] = { ...newItems[index], [field]: value };
|
const item = { ...newItems[index], [field]: value };
|
||||||
|
|
||||||
|
const qty = parseFloat(item.quantity_received) || 0;
|
||||||
|
const price = parseFloat(item.unit_price) || 0;
|
||||||
|
const subtotal = parseFloat(item.subtotal) || 0;
|
||||||
|
|
||||||
|
if (field === 'quantity_received') {
|
||||||
|
// 修改數量 -> 更新小計 (保持單價)
|
||||||
|
item.subtotal = (qty * price).toString();
|
||||||
|
} else if (field === 'unit_price') {
|
||||||
|
// 修改單價 -> 更新小計 (價格 * 數量)
|
||||||
|
item.subtotal = (qty * price).toString();
|
||||||
|
} else if (field === 'subtotal') {
|
||||||
|
// 修改小計 -> 更新單價 (小計 / 數量)
|
||||||
|
if (qty > 0) {
|
||||||
|
item.unit_price = (subtotal / qty).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems[index] = item;
|
||||||
setData('items', newItems);
|
setData('items', newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,23 +342,23 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
data.items.forEach((item, index) => {
|
data.items.forEach((item, index) => {
|
||||||
if (item.batchMode === 'new' && item.originCountry && data.received_date) {
|
if (item.batchMode === 'none') {
|
||||||
|
if (item.batch_number !== 'NO-BATCH') {
|
||||||
|
const newItems = [...data.items];
|
||||||
|
newItems[index].batch_number = 'NO-BATCH';
|
||||||
|
setData('items', newItems);
|
||||||
|
}
|
||||||
|
} else if (item.batchMode === 'new' && item.originCountry && data.received_date) {
|
||||||
const country = item.originCountry;
|
const country = item.originCountry;
|
||||||
// Use date from form or today
|
// Use date from form or today
|
||||||
const dateStr = data.received_date || new Date().toISOString().split('T')[0];
|
const dateStr = data.received_date || new Date().toISOString().split('T')[0];
|
||||||
const seqKey = `${item.product_id}-${country}-${dateStr}`;
|
const seqKey = `${item.product_id}-${country}-${dateStr}`;
|
||||||
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
|
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
|
||||||
|
|
||||||
// Only generate if we have a sequence (or default)
|
|
||||||
// Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences
|
|
||||||
|
|
||||||
const datePart = dateStr.replace(/-/g, '');
|
const datePart = dateStr.replace(/-/g, '');
|
||||||
const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`;
|
const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`;
|
||||||
|
|
||||||
if (item.batch_number !== generatedBatch) {
|
if (item.batch_number !== generatedBatch) {
|
||||||
// Update WITHOUT triggering re-render loop
|
|
||||||
// Need a way to update item silently or check condition carefully
|
|
||||||
// Using setBatchNumber might trigger this effect again but value will be same.
|
|
||||||
const newItems = [...data.items];
|
const newItems = [...data.items];
|
||||||
newItems[index].batch_number = generatedBatch;
|
newItems[index].batch_number = generatedBatch;
|
||||||
setData('items', newItems);
|
setData('items', newItems);
|
||||||
@@ -289,25 +370,54 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
const submit = async (e: React.FormEvent, force: boolean = false) => {
|
const submit = async (e: React.FormEvent, force: boolean = false) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
|
||||||
// 如果不是強制提交,先檢查重複
|
// 格式化日期數據
|
||||||
|
const formattedItems = data.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
expiry_date: item.expiry_date && item.expiry_date.includes('T')
|
||||||
|
? item.expiry_date.split('T')[0]
|
||||||
|
: item.expiry_date
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedDate = data.received_date.includes('T')
|
||||||
|
? data.received_date.split('T')[0]
|
||||||
|
: data.received_date;
|
||||||
|
|
||||||
|
// 建立一個臨時的提交資料對象
|
||||||
|
const submitData = {
|
||||||
|
...data,
|
||||||
|
received_date: formattedDate,
|
||||||
|
items: formattedItems
|
||||||
|
};
|
||||||
|
|
||||||
|
// 編輯模式直接 PUT 更新
|
||||||
|
if (isEditMode) {
|
||||||
|
// 使用 router.put 因為 useForm 的 put 不支援傳入自定義 data 物件而不影響 state
|
||||||
|
// 或者先 setData 再 put,但 setData 是非同步的
|
||||||
|
// 這裡採用在提交前手動傳遞格式化後的資料給 router
|
||||||
|
router.put(route('goods-receipts.update', receipt!.id), submitData, {
|
||||||
|
onSuccess: () => setWarningOpen(false),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增模式:先檢查重複
|
||||||
if (!force) {
|
if (!force) {
|
||||||
setIsCheckingDuplicate(true);
|
setIsCheckingDuplicate(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(route('goods-receipts.check-duplicate'), data);
|
const response = await axios.post(route('goods-receipts.check-duplicate'), submitData);
|
||||||
if (response.data.has_warnings) {
|
if (response.data.has_warnings) {
|
||||||
setWarnings(response.data.warnings);
|
setWarnings(response.data.warnings);
|
||||||
setWarningOpen(true);
|
setWarningOpen(true);
|
||||||
return; // 停止並顯示警告
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Duplicate check failed", error);
|
console.error("Duplicate check failed", error);
|
||||||
// 檢查失敗則繼續,或視為阻擋?這裡選擇繼續
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingDuplicate(false);
|
setIsCheckingDuplicate(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post(route('goods-receipts.store'), {
|
router.post(route('goods-receipts.store'), submitData, {
|
||||||
onSuccess: () => setWarningOpen(false),
|
onSuccess: () => setWarningOpen(false),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -319,28 +429,33 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: '供應鏈管理', href: '#' },
|
{ label: '供應鏈管理', href: '#' },
|
||||||
{ label: '進貨單管理', href: route('goods-receipts.index') },
|
{ label: '進貨單管理', href: route('goods-receipts.index') },
|
||||||
{ label: '新增進貨單', href: route('goods-receipts.create'), isPage: true },
|
{ label: isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單', href: isEditMode ? route('goods-receipts.edit', receipt!.id) : route('goods-receipts.create'), isPage: true },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Head title="新增進貨單" />
|
<Head title={isEditMode ? `編輯進貨單 - ${receipt!.code}` : '新增進貨單'} />
|
||||||
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href={route('goods-receipts.index')}>
|
<Link href={route('goods-receipts.index')}>
|
||||||
<Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary">
|
<Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary" onClick={(e) => {
|
||||||
|
if (isEditMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
返回進貨單
|
{isEditMode ? '返回' : '返回進貨單'}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<Package className="h-6 w-6 text-primary-main" />
|
<Package className="h-6 w-6 text-primary-main" />
|
||||||
新增進貨單
|
{isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">
|
||||||
建立新的進貨單並入庫
|
{isEditMode ? '修改進貨單內容' : '建立新的進貨單並入庫'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,6 +473,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isEditMode) return; // 編輯模式禁止切換類型
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
type: t.id,
|
type: t.id,
|
||||||
@@ -368,10 +484,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
setSelectedPO(null);
|
setSelectedPO(null);
|
||||||
if (t.id !== 'standard') setSelectedVendor(null);
|
if (t.id !== 'standard') setSelectedVendor(null);
|
||||||
}}
|
}}
|
||||||
|
disabled={isEditMode}
|
||||||
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
|
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
|
||||||
? 'border-primary-main bg-primary-main/5'
|
? 'border-primary-main bg-primary-main/5'
|
||||||
: 'border-gray-100 hover:border-gray-200'
|
: 'border-gray-100 hover:border-gray-200'
|
||||||
}`}
|
} ${isEditMode ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||||
>
|
>
|
||||||
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
|
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -457,9 +574,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
<span className="font-bold text-gray-800">{selectedPO.items.length} 項</span>
|
<span className="font-bold text-gray-800">{selectedPO.items.length} 項</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!isEditMode && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
||||||
重新選擇
|
重新選擇
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -497,9 +616,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
|
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!isEditMode && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
|
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
|
||||||
重新選擇
|
重新選擇
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -507,7 +628,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step 2: Details & Items */}
|
{/* Step 2: Details & Items */}
|
||||||
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
|
{(isEditMode || (data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
|
||||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
|
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
|
||||||
@@ -560,42 +681,24 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-bold text-gray-700">商品明細</h3>
|
<h3 className="font-bold text-gray-700">商品明細</h3>
|
||||||
{data.type !== 'standard' && (
|
{isNonStandard && (
|
||||||
<div className="flex gap-2 items-center">
|
<Button
|
||||||
<div className="relative">
|
onClick={handleAddEmptyItem}
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
|
className="button-filled-primary h-10 gap-2"
|
||||||
<Input
|
|
||||||
placeholder="搜尋商品加入..."
|
|
||||||
value={productSearch}
|
|
||||||
onChange={(e) => setProductSearch(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && searchProducts()}
|
|
||||||
className="h-9 w-64 pl-9"
|
|
||||||
/>
|
|
||||||
{foundProducts.length > 0 && (
|
|
||||||
<div className="absolute top-10 left-0 w-full bg-white border rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
|
|
||||||
{foundProducts.map(p => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
onClick={() => handleAddProduct(p)}
|
|
||||||
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-0 flex flex-col"
|
|
||||||
>
|
>
|
||||||
<span className="font-bold text-sm">{p.name}</span>
|
<Plus className="h-4 w-4" /> 新增品項
|
||||||
<span className="text-xs text-gray-500">{p.code}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button onClick={searchProducts} disabled={isSearching} size="sm" className="button-filled-primary h-9">
|
|
||||||
加入
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calculated Totals for usage in Table Footer or Summary */}
|
{/* Calculated Totals for usage in Table Footer or Summary */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const subTotal = data.items.reduce((acc, item) => {
|
const subTotal = data.items.reduce((acc, item) => {
|
||||||
|
if (isNonStandard) {
|
||||||
|
// 非標準類型:使用手動輸入的小計
|
||||||
|
return acc + (parseFloat(item.subtotal) || 0);
|
||||||
|
}
|
||||||
|
// 標準類型:自動計算 qty × price
|
||||||
const qty = parseFloat(item.quantity_received) || 0;
|
const qty = parseFloat(item.quantity_received) || 0;
|
||||||
const price = parseFloat(item.unit_price) || 0;
|
const price = parseFloat(item.unit_price) || 0;
|
||||||
return acc + (qty * price);
|
return acc + (qty * price);
|
||||||
@@ -609,21 +712,28 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-gray-50/50">
|
<TableHeader className="bg-gray-50/50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[180px]">商品資訊</TableHead>
|
<TableHead className={isNonStandard ? 'w-[220px]' : 'w-[180px]'}>商品資訊</TableHead>
|
||||||
|
{!isNonStandard && (
|
||||||
|
<>
|
||||||
<TableHead className="w-[80px] text-center">總數量</TableHead>
|
<TableHead className="w-[80px] text-center">總數量</TableHead>
|
||||||
<TableHead className="w-[80px] text-center">待收貨</TableHead>
|
<TableHead className="w-[80px] text-center">待收貨</TableHead>
|
||||||
<TableHead className="w-[120px]">本次收貨 <span className="text-red-500">*</span></TableHead>
|
</>
|
||||||
|
)}
|
||||||
|
<TableHead className="w-[120px]">{isNonStandard ? '數量' : '本次收貨'} <span className="text-red-500">*</span></TableHead>
|
||||||
|
{isNonStandard && (
|
||||||
|
<TableHead className="w-[100px]">單價</TableHead>
|
||||||
|
)}
|
||||||
<TableHead className="w-[200px]">批號設定 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[200px]">批號設定 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[150px]">效期</TableHead>
|
<TableHead className="w-[150px]">效期</TableHead>
|
||||||
<TableHead className="w-[80px] text-right">小計</TableHead>
|
<TableHead className={isNonStandard ? 'w-[120px]' : 'w-[80px] text-right'}>小計{isNonStandard && <> <span className="text-red-500">*</span></>}</TableHead>
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center py-8 text-gray-400 italic">
|
<TableCell colSpan={isNonStandard ? 6 : 8} className="text-center py-8 text-gray-400 italic">
|
||||||
尚無明細,請搜尋商品加入。
|
{isNonStandard ? '尚無明細,請點擊「新增品項」加入。' : '尚無明細,請搜尋商品加入。'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -635,28 +745,45 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
|
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
|
||||||
{/* Product Info */}
|
{/* Product Info */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
{isNonStandard && !item.product_id ? (
|
||||||
|
<SearchableSelect
|
||||||
|
value=""
|
||||||
|
onValueChange={(val) => handleSelectProduct(index, val)}
|
||||||
|
options={allProducts.map(p => ({
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
value: p.id.toString()
|
||||||
|
}))}
|
||||||
|
placeholder={isLoadingProducts ? '載入中...' : '選擇商品...'}
|
||||||
|
searchPlaceholder="搜尋商品名稱或代碼..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-gray-900">{item.product_name}</span>
|
<span className="font-medium text-gray-900">{item.product_name}</span>
|
||||||
<span className="text-xs text-gray-500">{item.product_code}</span>
|
<span className="text-xs text-gray-500">{item.product_code}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Total Quantity */}
|
{/* Total Quantity & Remaining - 僅標準採購顯示 */}
|
||||||
|
{!isNonStandard && (
|
||||||
|
<>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<span className="text-gray-500 text-sm">
|
<span className="text-gray-500 text-sm">
|
||||||
{Math.round(item.quantity_ordered)}
|
{Math.round(item.quantity_ordered)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Remaining */}
|
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<span className="text-gray-900 font-medium text-sm">
|
<span className="text-gray-900 font-medium text-sm">
|
||||||
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
|
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Received Quantity */}
|
{/* Received Quantity */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -665,25 +792,100 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
|
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
|
||||||
className={`w-full text-right ${errors && (errors as any)[errorKey] ? 'border-red-500' : ''}`}
|
className={`w-full text-right ${errors && (errors as any)[errorKey] ? 'border-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{item.selectedUnit === 'large' && item.conversion_rate > 1 && (
|
||||||
|
<div className="text-[10px] text-primary-main text-right font-medium">
|
||||||
|
= {(parseFloat(item.quantity_received) * item.conversion_rate).toLocaleString()} {item.base_unit_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{(errors as any)[errorKey] && (
|
{(errors as any)[errorKey] && (
|
||||||
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
|
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Unit Price - 僅非標準類型顯示 */}
|
||||||
|
{isNonStandard && (
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
value={item.unit_price || ''}
|
||||||
|
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
|
||||||
|
className="w-full text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Batch Settings */}
|
{/* Batch Settings */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* 統一批號選擇器 */}
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.batchMode === 'none' ? 'no_batch' : (item.batchMode === 'new' ? 'new_batch' : (item.inventory_id || ""))}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'new_batch') {
|
||||||
|
const updatedItem = {
|
||||||
|
...item,
|
||||||
|
batchMode: 'new',
|
||||||
|
inventory_id: undefined,
|
||||||
|
originCountry: 'TW',
|
||||||
|
expiry_date: '',
|
||||||
|
};
|
||||||
|
const newItems = [...data.items];
|
||||||
|
newItems[index] = updatedItem;
|
||||||
|
setData('items', newItems);
|
||||||
|
} else if (value === 'no_batch') {
|
||||||
|
const updatedItem = {
|
||||||
|
...item,
|
||||||
|
batchMode: 'none',
|
||||||
|
batch_number: 'NO-BATCH',
|
||||||
|
inventory_id: undefined,
|
||||||
|
originCountry: 'TW',
|
||||||
|
expiry_date: '',
|
||||||
|
};
|
||||||
|
const newItems = [...data.items];
|
||||||
|
newItems[index] = updatedItem;
|
||||||
|
setData('items', newItems);
|
||||||
|
} else {
|
||||||
|
// 選擇現有批號 (如果有快照的話,目前架構下先保留 basic 選項)
|
||||||
|
updateItem(index, 'batchMode', 'existing');
|
||||||
|
updateItem(index, 'inventory_id', value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: "📦 不使用批號 (自動累加)", value: "no_batch" },
|
||||||
|
{ label: "+ 建立新批號", value: "new_batch" },
|
||||||
|
// 若有現有批號列表可在此擴充,目前進貨單主要處理 new/none
|
||||||
|
]}
|
||||||
|
placeholder="選擇或建立批號"
|
||||||
|
className="border-gray-200"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 建立新批號時的附加欄位 */}
|
||||||
|
{item.batchMode === 'new' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={item.originCountry || 'TW'}
|
value={item.originCountry || 'TW'}
|
||||||
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
|
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
|
||||||
placeholder="產地"
|
placeholder="產地"
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
className="w-16 text-center px-1"
|
className="w-12 h-8 text-center px-1 text-xs"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate">
|
<div className="flex-1 text-[10px] font-mono px-2 py-1.5 rounded truncate bg-primary-50 text-primary-main border border-primary-100 flex items-center">
|
||||||
{getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)}
|
{getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 不使用批號時的提示 */}
|
||||||
|
{item.batchMode === 'none' && (
|
||||||
|
<p className="text-[10px] text-amber-600 bg-amber-50/50 px-2 py-1 rounded border border-amber-100/50">
|
||||||
|
系統將自動累計至該商品的通用庫存紀錄
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Expiry Date */}
|
{/* Expiry Date */}
|
||||||
@@ -701,8 +903,20 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Subtotal */}
|
{/* Subtotal */}
|
||||||
<TableCell className="text-right font-medium">
|
<TableCell className={isNonStandard ? '' : 'text-right font-medium'}>
|
||||||
${itemTotal.toLocaleString()}
|
{isNonStandard ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
value={item.subtotal || ''}
|
||||||
|
onChange={(e) => updateItem(index, 'subtotal', e.target.value)}
|
||||||
|
className="w-full text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>${itemTotal.toLocaleString()}</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -765,10 +979,10 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
|
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={processing || isCheckingDuplicate || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
|
disabled={processing || isCheckingDuplicate || (!isEditMode && (data.type === 'standard' ? !selectedPO : !selectedVendor))}
|
||||||
>
|
>
|
||||||
<Save className="mr-2 h-5 w-5" />
|
<Save className="mr-2 h-5 w-5" />
|
||||||
{processing || isCheckingDuplicate ? '處理中...' : '確認進貨'}
|
{processing || isCheckingDuplicate ? '處理中...' : (isEditMode ? '儲存變更' : '確認進貨')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Head, router } from "@inertiajs/react";
|
import { Head, router } from "@inertiajs/react";
|
||||||
|
import { debounce } from "lodash";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -126,9 +127,14 @@ export default function StockQueryIndex({
|
|||||||
filters.per_page || inventories.per_page?.toString() || "10"
|
filters.per_page || inventories.per_page?.toString() || "10"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 執行篩選
|
// 同步 URL 的 search 參數到 local state
|
||||||
const applyFilters = (newFilters: Record<string, string | undefined>) => {
|
useEffect(() => {
|
||||||
const merged = { ...filters, ...newFilters, page: undefined };
|
setSearch(filters.search || "");
|
||||||
|
}, [filters.search]);
|
||||||
|
|
||||||
|
// 執行篩選核心
|
||||||
|
const applyFiltersWithOptions = (newFilters: Record<string, string | undefined>, currentFilters: typeof filters) => {
|
||||||
|
const merged = { ...currentFilters, ...newFilters, page: undefined };
|
||||||
// 移除空值
|
// 移除空值
|
||||||
const cleaned: Record<string, string> = {};
|
const cleaned: Record<string, string> = {};
|
||||||
Object.entries(merged).forEach(([key, value]) => {
|
Object.entries(merged).forEach(([key, value]) => {
|
||||||
@@ -143,8 +149,27 @@ export default function StockQueryIndex({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 搜尋
|
const applyFilters = (newFilters: Record<string, string | undefined>) => {
|
||||||
|
applyFiltersWithOptions(newFilters, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced Search Handler
|
||||||
|
const debouncedSearch = useCallback(
|
||||||
|
debounce((term: string, currentFilters: typeof filters) => {
|
||||||
|
applyFiltersWithOptions({ search: term || undefined }, currentFilters);
|
||||||
|
}, 500),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 搜尋值的改變
|
||||||
|
const handleSearchChange = (val: string) => {
|
||||||
|
setSearch(val);
|
||||||
|
debouncedSearch(val, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 點擊搜尋按鈕或按下 Enter 鍵立即搜尋
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
|
debouncedSearch.cancel();
|
||||||
applyFilters({ search: search || undefined });
|
applyFilters({ search: search || undefined });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -372,9 +397,9 @@ export default function StockQueryIndex({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
onKeyDown={handleSearchKeyDown}
|
onKeyDown={handleSearchKeyDown}
|
||||||
placeholder="搜尋商品代碼或名稱..."
|
placeholder="搜尋商品代碼或名稱或批號..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ interface InventoryOption {
|
|||||||
base_unit_name?: string;
|
base_unit_name?: string;
|
||||||
large_unit_id?: number;
|
large_unit_id?: number;
|
||||||
large_unit_name?: string;
|
large_unit_name?: string;
|
||||||
|
purchase_unit_id?: number;
|
||||||
conversion_rate?: number;
|
conversion_rate?: number;
|
||||||
|
unit_cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BomItem {
|
interface BomItem {
|
||||||
@@ -73,6 +75,8 @@ interface BomItem {
|
|||||||
ui_large_unit_name?: string;
|
ui_large_unit_name?: string;
|
||||||
ui_base_unit_id?: number;
|
ui_base_unit_id?: number;
|
||||||
ui_large_unit_id?: number;
|
ui_large_unit_id?: number;
|
||||||
|
ui_purchase_unit_id?: number;
|
||||||
|
ui_unit_cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -92,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
const [recipes, setRecipes] = useState<any[]>([]);
|
const [recipes, setRecipes] = useState<any[]>([]);
|
||||||
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
||||||
|
|
||||||
const { data, setData, processing, errors } = useForm({
|
// 提交表單
|
||||||
|
const { data, setData, processing, errors, setError, clearErrors } = useForm({
|
||||||
product_id: "",
|
product_id: "",
|
||||||
warehouse_id: "",
|
warehouse_id: "",
|
||||||
output_quantity: "",
|
output_quantity: "",
|
||||||
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
|
|
||||||
remark: "",
|
remark: "",
|
||||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||||
});
|
});
|
||||||
@@ -104,7 +108,6 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
// 獲取特定商品在各倉庫的庫存分佈
|
// 獲取特定商品在各倉庫的庫存分佈
|
||||||
const fetchProductInventories = async (productId: string) => {
|
const fetchProductInventories = async (productId: string) => {
|
||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
|
|
||||||
if (loadingProducts[productId]) return;
|
if (loadingProducts[productId]) return;
|
||||||
|
|
||||||
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||||
@@ -155,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
item.unit_id = "";
|
item.unit_id = "";
|
||||||
item.ui_input_quantity = "";
|
item.ui_input_quantity = "";
|
||||||
item.ui_selected_unit = "base";
|
item.ui_selected_unit = "base";
|
||||||
// 清除 cache 資訊
|
|
||||||
delete item.ui_product_name;
|
delete item.ui_product_name;
|
||||||
delete item.ui_batch_number;
|
delete item.ui_batch_number;
|
||||||
delete item.ui_available_qty;
|
delete item.ui_available_qty;
|
||||||
@@ -176,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
|
|
||||||
if (field === 'ui_warehouse_id') {
|
if (field === 'ui_warehouse_id') {
|
||||||
item.inventory_id = "";
|
item.inventory_id = "";
|
||||||
// 不重置數量
|
|
||||||
// item.quantity_used = "";
|
|
||||||
// item.ui_input_quantity = "";
|
|
||||||
// item.ui_selected_unit = "base";
|
|
||||||
|
|
||||||
// 清除某些 cache
|
|
||||||
delete item.ui_batch_number;
|
delete item.ui_batch_number;
|
||||||
delete item.ui_available_qty;
|
delete item.ui_available_qty;
|
||||||
delete item.ui_expiry_date;
|
delete item.ui_expiry_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
|
|
||||||
if (field === 'inventory_id' && value) {
|
if (field === 'inventory_id' && value) {
|
||||||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||||
const inv = currentOptions.find(i => String(i.id) === value);
|
const inv = currentOptions.find(i => String(i.id) === value);
|
||||||
@@ -199,42 +193,31 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
item.ui_batch_number = inv.batch_number;
|
item.ui_batch_number = inv.batch_number;
|
||||||
item.ui_available_qty = inv.quantity;
|
item.ui_available_qty = inv.quantity;
|
||||||
item.ui_expiry_date = inv.expiry_date || '';
|
item.ui_expiry_date = inv.expiry_date || '';
|
||||||
|
|
||||||
// 單位與轉換率
|
|
||||||
item.ui_base_unit_name = inv.unit_name || '';
|
item.ui_base_unit_name = inv.unit_name || '';
|
||||||
item.ui_base_unit_id = inv.base_unit_id;
|
item.ui_base_unit_id = inv.base_unit_id;
|
||||||
|
item.ui_large_unit_id = inv.large_unit_id;
|
||||||
|
item.ui_purchase_unit_id = inv.purchase_unit_id;
|
||||||
item.ui_conversion_rate = inv.conversion_rate || 1;
|
item.ui_conversion_rate = inv.conversion_rate || 1;
|
||||||
|
item.ui_unit_cost = inv.unit_cost || 0;
|
||||||
// 預設單位
|
|
||||||
item.ui_selected_unit = 'base';
|
item.ui_selected_unit = 'base';
|
||||||
item.unit_id = String(inv.base_unit_id || '');
|
item.unit_id = String(inv.base_unit_id || '');
|
||||||
|
|
||||||
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
|
|
||||||
if (!item.ui_input_quantity) {
|
if (!item.ui_input_quantity) {
|
||||||
item.ui_input_quantity = formatQuantity(inv.quantity);
|
item.ui_input_quantity = formatQuantity(inv.quantity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 計算最終數量 (Base Quantity)
|
|
||||||
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
||||||
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||||||
const rate = item.ui_conversion_rate || 1;
|
const rate = item.ui_conversion_rate || 1;
|
||||||
|
item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty);
|
||||||
if (item.ui_selected_unit === 'large') {
|
|
||||||
item.quantity_used = String(inputQty * rate);
|
|
||||||
item.unit_id = String(item.ui_base_unit_id || '');
|
item.unit_id = String(item.ui_base_unit_id || '');
|
||||||
} else {
|
|
||||||
item.quantity_used = String(inputQty);
|
|
||||||
item.unit_id = String(item.ui_base_unit_id || '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updated[index] = item;
|
updated[index] = item;
|
||||||
setBomItems(updated);
|
setBomItems(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步 BOM items 到表單 data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData('items', bomItems.map(item => ({
|
setData('items', bomItems.map(item => ({
|
||||||
inventory_id: Number(item.inventory_id),
|
inventory_id: Number(item.inventory_id),
|
||||||
@@ -243,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
})));
|
})));
|
||||||
}, [bomItems]);
|
}, [bomItems]);
|
||||||
|
|
||||||
// 應用配方到表單 (獨立函式)
|
|
||||||
const applyRecipe = (recipe: any) => {
|
const applyRecipe = (recipe: any) => {
|
||||||
if (!recipe || !recipe.items) return;
|
if (!recipe || !recipe.items) return;
|
||||||
|
|
||||||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||||||
// 自動帶入配方標準產量
|
setData('output_quantity', formatQuantity(yieldQty));
|
||||||
setData('output_quantity', String(yieldQty));
|
|
||||||
|
|
||||||
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
||||||
const baseQty = parseFloat(item.quantity || "0");
|
if (item.product_id) fetchProductInventories(String(item.product_id));
|
||||||
const calculatedQty = baseQty; // 保持精度
|
|
||||||
|
|
||||||
// 若有配方商品,預先載入庫存分佈
|
|
||||||
if (item.product_id) {
|
|
||||||
fetchProductInventories(String(item.product_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inventory_id: "",
|
inventory_id: "",
|
||||||
quantity_used: String(calculatedQty),
|
quantity_used: String(item.quantity || "0"),
|
||||||
unit_id: String(item.unit_id),
|
unit_id: String(item.unit_id),
|
||||||
ui_warehouse_id: "",
|
ui_warehouse_id: "",
|
||||||
ui_product_id: String(item.product_id),
|
ui_product_id: String(item.product_id),
|
||||||
ui_product_name: item.product_name,
|
ui_product_name: item.product_name,
|
||||||
ui_batch_number: "",
|
ui_batch_number: "",
|
||||||
ui_available_qty: 0,
|
ui_available_qty: 0,
|
||||||
ui_input_quantity: String(calculatedQty),
|
ui_input_quantity: formatQuantity(item.quantity || "0"),
|
||||||
ui_selected_unit: 'base',
|
ui_selected_unit: 'base',
|
||||||
ui_base_unit_name: item.unit_name,
|
ui_base_unit_name: item.unit_name,
|
||||||
ui_base_unit_id: item.unit_id,
|
ui_base_unit_id: item.unit_id,
|
||||||
@@ -277,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
setBomItems(newBomItems);
|
setBomItems(newBomItems);
|
||||||
|
toast.success(`已自動載入配方: ${recipe.name}`);
|
||||||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
|
||||||
description: `標準產量: ${yieldQty} 份`
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 當手動切換配方時
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedRecipeId) return;
|
if (!selectedRecipeId) return;
|
||||||
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
||||||
if (targetRecipe) {
|
if (targetRecipe) applyRecipe(targetRecipe);
|
||||||
applyRecipe(targetRecipe);
|
|
||||||
}
|
|
||||||
}, [selectedRecipeId]);
|
}, [selectedRecipeId]);
|
||||||
|
|
||||||
// 自動產生成品批號與載入配方
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data.product_id) return;
|
if (!data.product_id) return;
|
||||||
|
|
||||||
// 2. 自動載入配方列表
|
|
||||||
const fetchRecipes = async () => {
|
const fetchRecipes = async () => {
|
||||||
try {
|
try {
|
||||||
// 改為抓取所有配方
|
|
||||||
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
||||||
const recipesData = await res.json();
|
const recipesData = await res.json();
|
||||||
|
|
||||||
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
||||||
setRecipes(recipesData);
|
setRecipes(recipesData);
|
||||||
// 預設選取最新的 (第一個)
|
setSelectedRecipeId(String(recipesData[0].id));
|
||||||
const latest = recipesData[0];
|
|
||||||
setSelectedRecipeId(String(latest.id));
|
|
||||||
} else {
|
} else {
|
||||||
// 若無配方
|
|
||||||
setRecipes([]);
|
setRecipes([]);
|
||||||
setSelectedRecipeId("");
|
setSelectedRecipeId("");
|
||||||
setBomItems([]); // 清空 BOM
|
setBomItems([]);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch recipes", e);
|
|
||||||
setRecipes([]);
|
setRecipes([]);
|
||||||
setBomItems([]);
|
setBomItems([]);
|
||||||
}
|
}
|
||||||
@@ -323,63 +280,94 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
fetchRecipes();
|
fetchRecipes();
|
||||||
}, [data.product_id]);
|
}, [data.product_id]);
|
||||||
|
|
||||||
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
|
// 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bomItems.length > 0 && data.output_quantity) {
|
const errorKeys = Object.keys(errors);
|
||||||
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
|
if (errorKeys.length > 0) {
|
||||||
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
|
// 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
|
||||||
// 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
|
setTimeout(() => {
|
||||||
|
const firstInvalid = document.querySelector('[aria-invalid="true"]');
|
||||||
|
if (firstInvalid instanceof HTMLElement) {
|
||||||
|
firstInvalid.focus();
|
||||||
|
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
}, [data.output_quantity]);
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [errors]);
|
||||||
|
|
||||||
// 提交表單
|
const submit = (status: 'draft') => {
|
||||||
const submit = (status: 'draft' | 'completed') => {
|
clearErrors();
|
||||||
// 驗證(簡單前端驗證,完整驗證在後端)
|
let hasError = false;
|
||||||
if (status === 'completed') {
|
|
||||||
const missingFields = [];
|
|
||||||
if (!data.product_id) missingFields.push('成品商品');
|
|
||||||
if (!data.output_quantity) missingFields.push('生產數量');
|
|
||||||
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
|
|
||||||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
// 草稿建立時也要求必填生產數量與預計入庫倉庫
|
||||||
toast.error("請填寫必要欄位", {
|
if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
|
||||||
description: `缺漏:${missingFields.join('、')}`
|
if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
|
||||||
|
if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
|
||||||
|
if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; }
|
||||||
|
|
||||||
|
// 驗證 BOM 明細
|
||||||
|
bomItems.forEach((item, index) => {
|
||||||
|
if (!item.ui_product_id) {
|
||||||
|
setError(`items.${index}.ui_product_id` as any, '請選擇商品');
|
||||||
|
hasError = true;
|
||||||
|
} else {
|
||||||
|
if (!item.inventory_id) {
|
||||||
|
setError(`items.${index}.inventory_id` as any, '請選擇批號');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) {
|
||||||
|
setError(`items.${index}.quantity_used` as any, '請輸入數量');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
toast.error("建立失敗,請檢查標單內紅框欄位");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 轉換 BOM items 格式
|
const formattedItems = bomItems.map(item => ({
|
||||||
const formattedItems = bomItems
|
inventory_id: parseInt(item.inventory_id),
|
||||||
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
|
quantity_used: parseFloat(item.quantity_used),
|
||||||
.map(item => ({
|
|
||||||
inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null,
|
|
||||||
quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
|
|
||||||
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 使用 router.post 提交完整資料
|
|
||||||
router.post(route('production-orders.store'), {
|
router.post(route('production-orders.store'), {
|
||||||
...data,
|
...data,
|
||||||
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
|
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
|
||||||
items: formattedItems,
|
items: formattedItems,
|
||||||
status: status,
|
status: status,
|
||||||
}, {
|
}, {
|
||||||
onError: (errors) => {
|
onError: () => {
|
||||||
const errorCount = Object.keys(errors).length;
|
toast.error("建立失敗,請檢查表單");
|
||||||
toast.error("建立失敗,請檢查表單", {
|
|
||||||
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit('completed');
|
submit('draft');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBomItemUnitCost = (item: BomItem) => {
|
||||||
|
if (!item.ui_unit_cost) return 0;
|
||||||
|
let cost = Number(item.ui_unit_cost);
|
||||||
|
|
||||||
|
// Check if selected unit is large_unit or purchase_unit
|
||||||
|
if (item.unit_id && (String(item.unit_id) === String(item.ui_large_unit_id) || String(item.unit_id) === String(item.ui_purchase_unit_id))) {
|
||||||
|
cost = cost * Number(item.ui_conversion_rate || 1);
|
||||||
|
}
|
||||||
|
return cost;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalEstimatedCost = bomItems.reduce((sum, item) => {
|
||||||
|
if (!item.ui_input_quantity || !item.ui_unit_cost) return sum;
|
||||||
|
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||||||
|
const unitCost = getBomItemUnitCost(item);
|
||||||
|
return sum + (unitCost * inputQty);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
||||||
<Head title="建立生產單" />
|
<Head title="建立生產單" />
|
||||||
@@ -398,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
<Factory className="h-6 w-6 text-primary-main" />
|
<Factory className="h-6 w-6 text-primary-main" />
|
||||||
建立生產工單
|
建立生產工單
|
||||||
</h1>
|
</h1>
|
||||||
@@ -406,16 +394,20 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
建立新的生產排程,選擇原物料並記錄產出
|
建立新的生產排程,選擇原物料並記錄產出
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
onClick={() => submit('draft')}
|
onClick={() => submit('draft')}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
className="gap-2 button-filled-primary"
|
className="button-filled-primary gap-2"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
儲存工單 (草稿)
|
儲存工單 (草稿)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* 成品資訊 */}
|
{/* 成品資訊 */}
|
||||||
@@ -433,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
}))}
|
}))}
|
||||||
placeholder="選擇成品"
|
placeholder="選擇成品"
|
||||||
className="w-full h-9"
|
className="w-full h-9"
|
||||||
|
aria-invalid={!!errors.product_id}
|
||||||
/>
|
/>
|
||||||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
||||||
|
|
||||||
@@ -468,6 +461,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||||
placeholder="例如: 50"
|
placeholder="例如: 50"
|
||||||
className="h-9 font-mono"
|
className="h-9 font-mono"
|
||||||
|
aria-invalid={!!errors.output_quantity}
|
||||||
/>
|
/>
|
||||||
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -483,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
}))}
|
}))}
|
||||||
placeholder="選擇倉庫"
|
placeholder="選擇倉庫"
|
||||||
className="w-full h-9"
|
className="w-full h-9"
|
||||||
|
aria-invalid={!!errors.warehouse_id}
|
||||||
/>
|
/>
|
||||||
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -529,10 +524,12 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
<TableRow className="bg-gray-50/50">
|
<TableRow className="bg-gray-50/50">
|
||||||
<TableHead className="w-[18%]">商品 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[18%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[15%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[15%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[30%]">批號 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[20%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[12%]">數量 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[12%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[10%]">單位</TableHead>
|
<TableHead className="w-[10%]">單位</TableHead>
|
||||||
<TableHead className="w-[10%]"></TableHead>
|
<TableHead className="w-[10%] text-right">預估單價</TableHead>
|
||||||
|
<TableHead className="w-[10%] text-right">成本小計</TableHead>
|
||||||
|
<TableHead className="w-[5%]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -560,7 +557,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
.map((inv: InventoryOption) => ({
|
.map((inv: InventoryOption) => ({
|
||||||
label: inv.batch_number,
|
label: inv.batch_number,
|
||||||
value: String(inv.id),
|
value: String(inv.id),
|
||||||
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
|
sublabel: `(存:${formatQuantity(inv.quantity)} | 效:${inv.expiry_date || '無'})`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -573,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
options={productOptions}
|
options={productOptions}
|
||||||
placeholder="選擇商品"
|
placeholder="選擇商品"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
@@ -601,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!item.ui_warehouse_id}
|
disabled={!item.ui_warehouse_id}
|
||||||
|
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
|
||||||
/>
|
/>
|
||||||
{item.inventory_id && (() => {
|
{item.inventory_id && (() => {
|
||||||
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||||
@@ -628,6 +627,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
className="h-9 text-right"
|
className="h-9 text-right"
|
||||||
disabled={!item.inventory_id}
|
disabled={!item.inventory_id}
|
||||||
|
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
@@ -638,6 +638,19 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 5. 預估單價 */}
|
||||||
|
<TableCell className="align-top text-right">
|
||||||
|
<div className="h-9 flex items-center justify-end px-1 text-sm text-gray-600 font-medium">
|
||||||
|
{getBomItemUnitCost(item).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 6. 成本小計 */}
|
||||||
|
<TableCell className="align-top text-right">
|
||||||
|
<div className="h-9 flex items-center justify-end px-1 text-sm font-bold text-gray-900">
|
||||||
|
{(getBomItemUnitCost(item) * parseFloat(item.ui_input_quantity || '0')).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Button
|
<Button
|
||||||
@@ -660,9 +673,27 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||||
|
|
||||||
|
{/* 生產單預估總成本區塊 */}
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600">生產單總預估成本</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
|
||||||
|
<span className="text-sm font-medium text-gray-700">預估單位生產成本
|
||||||
|
<span className="text-xs text-gray-500 ml-1">(共 {Number(data.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} 份)</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-md font-bold text-primary-main">
|
||||||
|
{(parseFloat(data.output_quantity) > 0 ? totalEstimatedCost / parseFloat(data.output_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ interface InventoryOption {
|
|||||||
base_unit_name?: string;
|
base_unit_name?: string;
|
||||||
large_unit_id?: number;
|
large_unit_id?: number;
|
||||||
large_unit_name?: string;
|
large_unit_name?: string;
|
||||||
|
purchase_unit_id?: number;
|
||||||
conversion_rate?: number;
|
conversion_rate?: number;
|
||||||
|
unit_cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BomItem {
|
interface BomItem {
|
||||||
@@ -76,7 +78,9 @@ interface BomItem {
|
|||||||
ui_large_unit_name?: string;
|
ui_large_unit_name?: string;
|
||||||
ui_base_unit_id?: number;
|
ui_base_unit_id?: number;
|
||||||
ui_large_unit_id?: number;
|
ui_large_unit_id?: number;
|
||||||
|
ui_purchase_unit_id?: number;
|
||||||
ui_product_code?: string;
|
ui_product_code?: string;
|
||||||
|
ui_unit_cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductionOrderItem {
|
interface ProductionOrderItem {
|
||||||
@@ -165,6 +169,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
ui_batch_number: item.inventory?.batch_number,
|
ui_batch_number: item.inventory?.batch_number,
|
||||||
ui_available_qty: item.inventory?.quantity,
|
ui_available_qty: item.inventory?.quantity,
|
||||||
ui_expiry_date: item.inventory?.expiry_date,
|
ui_expiry_date: item.inventory?.expiry_date,
|
||||||
|
ui_unit_cost: 0,
|
||||||
}));
|
}));
|
||||||
const [bomItems, setBomItems] = useState<BomItem[]>(initialBomItems);
|
const [bomItems, setBomItems] = useState<BomItem[]>(initialBomItems);
|
||||||
|
|
||||||
@@ -203,7 +208,9 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
ui_large_unit_name: inv.large_unit_name || '',
|
ui_large_unit_name: inv.large_unit_name || '',
|
||||||
ui_base_unit_id: inv.base_unit_id,
|
ui_base_unit_id: inv.base_unit_id,
|
||||||
ui_large_unit_id: inv.large_unit_id,
|
ui_large_unit_id: inv.large_unit_id,
|
||||||
|
ui_purchase_unit_id: inv.purchase_unit_id,
|
||||||
ui_conversion_rate: inv.conversion_rate || 1,
|
ui_conversion_rate: inv.conversion_rate || 1,
|
||||||
|
ui_unit_cost: inv.unit_cost || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +284,9 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
item.ui_large_unit_name = inv.large_unit_name || '';
|
item.ui_large_unit_name = inv.large_unit_name || '';
|
||||||
item.ui_base_unit_id = inv.base_unit_id;
|
item.ui_base_unit_id = inv.base_unit_id;
|
||||||
item.ui_large_unit_id = inv.large_unit_id;
|
item.ui_large_unit_id = inv.large_unit_id;
|
||||||
|
item.ui_purchase_unit_id = inv.purchase_unit_id;
|
||||||
item.ui_conversion_rate = inv.conversion_rate || 1;
|
item.ui_conversion_rate = inv.conversion_rate || 1;
|
||||||
|
item.ui_unit_cost = inv.unit_cost || 0;
|
||||||
|
|
||||||
item.ui_selected_unit = 'base';
|
item.ui_selected_unit = 'base';
|
||||||
item.unit_id = String(inv.base_unit_id || '');
|
item.unit_id = String(inv.base_unit_id || '');
|
||||||
@@ -365,6 +374,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
submit('draft');
|
submit('draft');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBomItemUnitCost = (item: BomItem) => {
|
||||||
|
if (!item.ui_unit_cost) return 0;
|
||||||
|
let cost = Number(item.ui_unit_cost);
|
||||||
|
|
||||||
|
// Check if selected unit is large_unit or purchase_unit
|
||||||
|
if (item.unit_id && (String(item.unit_id) === String(item.ui_large_unit_id) || String(item.unit_id) === String(item.ui_purchase_unit_id))) {
|
||||||
|
cost = cost * Number(item.ui_conversion_rate || 1);
|
||||||
|
}
|
||||||
|
return cost;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalEstimatedCost = bomItems.reduce((sum, item) => {
|
||||||
|
if (!item.ui_input_quantity || !item.ui_unit_cost) return sum;
|
||||||
|
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||||||
|
const unitCost = getBomItemUnitCost(item);
|
||||||
|
return sum + (unitCost * inputQty);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
||||||
<Head title={`編輯生產單 - ${productionOrder.code}`} />
|
<Head title={`編輯生產單 - ${productionOrder.code}`} />
|
||||||
@@ -492,10 +519,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
<TableRow className="bg-gray-50/50">
|
<TableRow className="bg-gray-50/50">
|
||||||
<TableHead className="w-[18%]">商品 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[18%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[15%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[15%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[30%]">批號 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[20%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[12%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[12%]">單位</TableHead>
|
<TableHead className="w-[10%]">單位</TableHead>
|
||||||
<TableHead className="w-[10%]"></TableHead>
|
<TableHead className="w-[10%] text-right">預估單價</TableHead>
|
||||||
|
<TableHead className="w-[10%] text-right">成本小計</TableHead>
|
||||||
|
<TableHead className="w-[5%]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -523,7 +552,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
.map((inv: InventoryOption) => ({
|
.map((inv: InventoryOption) => ({
|
||||||
label: inv.batch_number,
|
label: inv.batch_number,
|
||||||
value: String(inv.id),
|
value: String(inv.id),
|
||||||
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
|
sublabel: `(存:${formatQuantity(inv.quantity)} | 效:${inv.expiry_date || '無'})`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
||||||
@@ -598,6 +627,18 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="align-top text-right">
|
||||||
|
<div className="h-9 flex items-center justify-end px-1 text-sm text-gray-600 font-medium">
|
||||||
|
{getBomItemUnitCost(item).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="align-top text-right">
|
||||||
|
<div className="h-9 flex items-center justify-end px-1 text-sm font-bold text-gray-900">
|
||||||
|
{(getBomItemUnitCost(item) * parseFloat(item.ui_input_quantity || '0')).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -621,8 +662,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600">生產單總預估成本</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
|
||||||
|
<span className="text-sm font-medium text-gray-700">預估單位生產成本
|
||||||
|
<span className="text-xs text-gray-500 ml-1">(共 {Number(data.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} 份)</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-md font-bold text-primary-main">
|
||||||
|
{(parseFloat(data.output_quantity) > 0 ? totalEstimatedCost / parseFloat(data.output_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
* 生產工單管理主頁面
|
* 生產工單管理主頁面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react';
|
import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { debounce } from "lodash";
|
||||||
import { formatQuantity } from "@/lib/utils";
|
import { formatQuantity } from "@/lib/utils";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
@@ -36,6 +37,8 @@ interface ProductionOrder {
|
|||||||
production_date: string;
|
production_date: string;
|
||||||
status: 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled';
|
status: 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
estimated_total_cost?: number;
|
||||||
|
estimated_unit_cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -75,16 +78,25 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
|||||||
setPerPage(filters.per_page || productionOrders.per_page?.toString() || "10");
|
setPerPage(filters.per_page || productionOrders.per_page?.toString() || "10");
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
const handleFilter = () => {
|
const debouncedFilter = useCallback(
|
||||||
router.get(
|
debounce((params: any) => {
|
||||||
route('production-orders.index'),
|
router.get(route("production-orders.index"), params, {
|
||||||
{
|
preserveState: true,
|
||||||
search,
|
replace: true,
|
||||||
status: status === 'all' ? undefined : status,
|
preserveScroll: true,
|
||||||
per_page: perPage,
|
});
|
||||||
},
|
}, 300),
|
||||||
{ preserveState: true, replace: true, preserveScroll: true }
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSearchChange = (term: string) => {
|
||||||
|
setSearch(term);
|
||||||
|
debouncedFilter({
|
||||||
|
...filters,
|
||||||
|
search: term,
|
||||||
|
status: status === "all" ? undefined : status,
|
||||||
|
per_page: perPage,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -127,16 +139,12 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="搜尋生產單號、批號、商品名稱..."
|
placeholder="搜尋生產單號、批號、商品名稱..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
className="pl-10 pr-10 h-9"
|
className="pl-10 pr-10 h-9"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => handleSearchChange("")}
|
||||||
setSearch("");
|
|
||||||
router.get(route('production-orders.index'), { ...filters, search: "" }, { preserveState: true, replace: true, preserveScroll: true });
|
|
||||||
}}
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -170,15 +178,6 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="button-outlined-primary"
|
|
||||||
onClick={handleFilter}
|
|
||||||
>
|
|
||||||
<Search className="w-4 h-4 mr-2" />
|
|
||||||
搜尋
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Can permission="production_orders.create">
|
<Can permission="production_orders.create">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNavigateToCreate}
|
onClick={handleNavigateToCreate}
|
||||||
@@ -201,6 +200,8 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
|||||||
<TableHead>成品</TableHead>
|
<TableHead>成品</TableHead>
|
||||||
<TableHead>成品批號</TableHead>
|
<TableHead>成品批號</TableHead>
|
||||||
<TableHead className="text-right">數量</TableHead>
|
<TableHead className="text-right">數量</TableHead>
|
||||||
|
<TableHead className="text-right">預估單位成本</TableHead>
|
||||||
|
<TableHead className="text-right">預估總成本</TableHead>
|
||||||
<TableHead>入庫倉庫</TableHead>
|
<TableHead>入庫倉庫</TableHead>
|
||||||
<TableHead>生產日期</TableHead>
|
<TableHead>生產日期</TableHead>
|
||||||
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
||||||
@@ -210,7 +211,7 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{productionOrders.data.length === 0 ? (
|
{productionOrders.data.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="h-32 text-center text-gray-500">
|
<TableCell colSpan={10} className="h-32 text-center text-gray-500">
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<Factory className="h-10 w-10 text-gray-300" />
|
<Factory className="h-10 w-10 text-gray-300" />
|
||||||
<p>尚無生產工單</p>
|
<p>尚無生產工單</p>
|
||||||
@@ -239,6 +240,12 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
|||||||
<TableCell className="text-right font-medium">
|
<TableCell className="text-right font-medium">
|
||||||
{formatQuantity(order.output_quantity)}
|
{formatQuantity(order.output_quantity)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium text-primary-main">
|
||||||
|
{Number(order.estimated_unit_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium text-gray-700">
|
||||||
|
{Number(order.estimated_total_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-gray-600">
|
<TableCell className="text-gray-600">
|
||||||
{order.warehouse?.name || '-'}
|
{order.warehouse?.name || '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ interface RecipeDetailModalProps {
|
|||||||
export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: RecipeDetailModalProps) {
|
export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: RecipeDetailModalProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getUnitCost = (product: any, unitId: string | number) => {
|
||||||
|
if (!product || !product.cost_price) return 0;
|
||||||
|
let cost = Number(product.cost_price);
|
||||||
|
|
||||||
|
if (String(unitId) === String(product.large_unit_id) || String(unitId) === String(product.purchase_unit_id)) {
|
||||||
|
cost = cost * Number(product.conversion_rate || 1);
|
||||||
|
}
|
||||||
|
return cost;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0">
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0">
|
||||||
@@ -92,7 +102,7 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium text-gray-700">標準產量</TableCell>
|
<TableCell className="font-medium text-gray-700">標準產量</TableCell>
|
||||||
<TableCell className="text-gray-900 font-medium">
|
<TableCell className="text-gray-900 font-medium">
|
||||||
{Number(recipe.yield_quantity).toLocaleString()} {recipe.product?.base_unit?.name || '份'}
|
{Number(recipe.yield_quantity).toLocaleString(undefined, { maximumFractionDigits: 4 })} {recipe.product?.base_unit?.name || '份'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{recipe.description && (
|
{recipe.description && (
|
||||||
@@ -120,12 +130,17 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
|
|||||||
<TableHead>原物料名稱 / 料號</TableHead>
|
<TableHead>原物料名稱 / 料號</TableHead>
|
||||||
<TableHead className="text-right">標準用量</TableHead>
|
<TableHead className="text-right">標準用量</TableHead>
|
||||||
<TableHead>單位</TableHead>
|
<TableHead>單位</TableHead>
|
||||||
|
<TableHead className="text-right">預估單價</TableHead>
|
||||||
|
<TableHead className="text-right">成本小計</TableHead>
|
||||||
<TableHead>備註</TableHead>
|
<TableHead>備註</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{recipe.items?.length > 0 ? (
|
{recipe.items?.length > 0 ? (
|
||||||
recipe.items.map((item: any, index: number) => (
|
recipe.items.map((item: any, index: number) => {
|
||||||
|
const unitCost = item.product ? getUnitCost(item.product, item.unit_id) : 0;
|
||||||
|
const subtotal = unitCost * Number(item.quantity);
|
||||||
|
return (
|
||||||
<TableRow key={index} className="hover:bg-gray-50/50">
|
<TableRow key={index} className="hover:bg-gray-50/50">
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -134,16 +149,23 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-medium text-gray-900">
|
<TableCell className="text-right font-medium text-gray-900">
|
||||||
{Number(item.quantity).toLocaleString()}
|
{Number(item.quantity).toLocaleString(undefined, { maximumFractionDigits: 4 })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-gray-600">
|
<TableCell className="text-gray-600">
|
||||||
{item.unit?.name || '-'}
|
{item.unit?.name || '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600">
|
||||||
|
{unitCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium text-gray-900">
|
||||||
|
{subtotal.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-gray-500 text-sm">
|
<TableCell className="text-gray-500 text-sm">
|
||||||
{item.remark || '-'}
|
{item.remark || '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="h-24 text-center text-gray-500">
|
<TableCell colSpan={4} className="h-24 text-center text-gray-500">
|
||||||
@@ -154,6 +176,20 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600">配方預估總成本</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{(recipe.items || []).reduce((sum: number, item: any) => sum + (getUnitCost(item.product, item.unit_id) * Number(item.quantity)), 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
|
||||||
|
<span className="text-sm font-medium text-gray-700">單位生產成本
|
||||||
|
<span className="text-xs text-gray-500 ml-1">(共 {recipe.yield_quantity} 份)</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-md font-bold text-primary-main">{(Number(recipe.yield_quantity) > 0 ? (recipe.items || []).reduce((sum: number, item: any) => sum + (getUnitCost(item.product, item.unit_id) * Number(item.quantity)), 0) / Number(recipe.yield_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface Product {
|
|||||||
code: string;
|
code: string;
|
||||||
base_unit_id?: number;
|
base_unit_id?: number;
|
||||||
large_unit_id?: number;
|
large_unit_id?: number;
|
||||||
|
purchase_unit_id?: number;
|
||||||
|
cost_price?: number;
|
||||||
|
conversion_rate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
@@ -108,6 +111,25 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
post(route('recipes.store'));
|
post(route('recipes.store'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUnitCost = (productId: string, unitId: string) => {
|
||||||
|
const product = products.find(p => String(p.id) === productId);
|
||||||
|
if (!product || !product.cost_price) return 0;
|
||||||
|
let cost = Number(product.cost_price);
|
||||||
|
|
||||||
|
// Check if selected unit is large_unit or purchase_unit
|
||||||
|
if (unitId && (String(unitId) === String(product.large_unit_id) || String(unitId) === String(product.purchase_unit_id))) {
|
||||||
|
cost = cost * Number(product.conversion_rate || 1);
|
||||||
|
}
|
||||||
|
return cost;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCost = data.items.reduce((sum, item) => {
|
||||||
|
const unitCost = getUnitCost(item.product_id, item.unit_id);
|
||||||
|
return sum + (unitCost * Number(item.quantity || 0));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const unitCost = Number(data.yield_quantity) > 0 ? totalCost / Number(data.yield_quantity) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "新增", isPage: true }])}>
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "新增", isPage: true }])}>
|
||||||
<Head title="新增配方" />
|
<Head title="新增配方" />
|
||||||
@@ -233,10 +255,12 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-gray-50">
|
<TableHeader className="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[35%]">原物料商品</TableHead>
|
<TableHead className="w-[30%]">原物料商品</TableHead>
|
||||||
<TableHead className="w-[20%]">標準用量</TableHead>
|
<TableHead className="w-[15%]">標準用量</TableHead>
|
||||||
<TableHead className="w-[20%]">單位</TableHead>
|
<TableHead className="w-[15%]">單位</TableHead>
|
||||||
<TableHead className="w-[20%]">備註</TableHead>
|
<TableHead className="w-[10%] text-right">預估單價</TableHead>
|
||||||
|
<TableHead className="w-[10%] text-right">成本小計</TableHead>
|
||||||
|
<TableHead className="w-[15%]">備註</TableHead>
|
||||||
<TableHead className="w-[5%]"></TableHead>
|
<TableHead className="w-[5%]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -272,10 +296,23 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
className="text-right"
|
className="text-right"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-middle">
|
<TableCell className="align-top">
|
||||||
<div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
|
<SearchableSelect
|
||||||
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
|
value={item.unit_id}
|
||||||
</div>
|
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||||
|
options={units.map(u => ({
|
||||||
|
label: u.name,
|
||||||
|
value: String(u.id)
|
||||||
|
}))}
|
||||||
|
placeholder="單位"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-middle text-right text-sm text-gray-600">
|
||||||
|
{getUnitCost(item.product_id, item.unit_id).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-middle text-right font-medium text-gray-900">
|
||||||
|
{(getUnitCost(item.product_id, item.unit_id) * Number(item.quantity || 0)).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
@@ -300,6 +337,23 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 配方成本總計區塊 */}
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600">配方預估總成本</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{totalCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
|
||||||
|
<span className="text-sm font-medium text-gray-700">單位生產成本
|
||||||
|
<span className="text-xs text-gray-500 ml-1">(共 {Number(data.yield_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} 份)</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-md font-bold text-primary-main">{unitCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface Product {
|
|||||||
code: string;
|
code: string;
|
||||||
base_unit_id?: number;
|
base_unit_id?: number;
|
||||||
large_unit_id?: number;
|
large_unit_id?: number;
|
||||||
|
purchase_unit_id?: number;
|
||||||
|
cost_price?: number;
|
||||||
|
conversion_rate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
@@ -73,10 +76,10 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
code: recipe.code,
|
code: recipe.code,
|
||||||
name: recipe.name,
|
name: recipe.name,
|
||||||
description: recipe.description || "",
|
description: recipe.description || "",
|
||||||
yield_quantity: String(recipe.yield_quantity),
|
yield_quantity: String(Number(recipe.yield_quantity || 0)),
|
||||||
items: recipe.items.map(item => ({
|
items: recipe.items.map(item => ({
|
||||||
product_id: String(item.product_id),
|
product_id: String(item.product_id),
|
||||||
quantity: String(item.quantity),
|
quantity: String(Number(item.quantity || 0)),
|
||||||
unit_id: String(item.unit_id),
|
unit_id: String(item.unit_id),
|
||||||
remark: item.remark || "",
|
remark: item.remark || "",
|
||||||
ui_product_name: item.product?.name,
|
ui_product_name: item.product?.name,
|
||||||
@@ -133,6 +136,25 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
put(route('recipes.update', recipe.id));
|
put(route('recipes.update', recipe.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUnitCost = (productId: string, unitId: string) => {
|
||||||
|
const product = products.find(p => String(p.id) === productId);
|
||||||
|
if (!product || !product.cost_price) return 0;
|
||||||
|
let cost = Number(product.cost_price);
|
||||||
|
|
||||||
|
// Check if selected unit is large_unit or purchase_unit
|
||||||
|
if (unitId && (String(unitId) === String(product.large_unit_id) || String(unitId) === String(product.purchase_unit_id))) {
|
||||||
|
cost = cost * Number(product.conversion_rate || 1);
|
||||||
|
}
|
||||||
|
return cost;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCost = data.items.reduce((sum, item) => {
|
||||||
|
const unitCost = getUnitCost(item.product_id, item.unit_id);
|
||||||
|
return sum + (unitCost * Number(item.quantity || 0));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const unitCost = Number(data.yield_quantity) > 0 ? totalCost / Number(data.yield_quantity) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "編輯", isPage: true }])}>
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "編輯", isPage: true }])}>
|
||||||
<Head title="編輯配方" />
|
<Head title="編輯配方" />
|
||||||
@@ -258,10 +280,12 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-gray-50">
|
<TableHeader className="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[35%]">原物料商品</TableHead>
|
<TableHead className="w-[30%]">原物料商品</TableHead>
|
||||||
<TableHead className="w-[20%]">標準用量</TableHead>
|
<TableHead className="w-[15%]">標準用量</TableHead>
|
||||||
<TableHead className="w-[20%]">單位</TableHead>
|
<TableHead className="w-[15%]">單位</TableHead>
|
||||||
<TableHead className="w-[20%]">備註</TableHead>
|
<TableHead className="w-[10%] text-right">預估單價</TableHead>
|
||||||
|
<TableHead className="w-[10%] text-right">成本小計</TableHead>
|
||||||
|
<TableHead className="w-[15%]">備註</TableHead>
|
||||||
<TableHead className="w-[5%]"></TableHead>
|
<TableHead className="w-[5%]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -297,10 +321,23 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
className="text-right"
|
className="text-right"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-middle">
|
<TableCell className="align-top">
|
||||||
<div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
|
<SearchableSelect
|
||||||
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
|
value={item.unit_id}
|
||||||
</div>
|
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||||
|
options={units.map(u => ({
|
||||||
|
label: u.name,
|
||||||
|
value: String(u.id)
|
||||||
|
}))}
|
||||||
|
placeholder="單位"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-middle text-right text-sm text-gray-600">
|
||||||
|
{getUnitCost(item.product_id, item.unit_id).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-middle text-right font-medium text-gray-900">
|
||||||
|
{(getUnitCost(item.product_id, item.unit_id) * Number(item.quantity || 0)).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
@@ -325,6 +362,23 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 配方成本總計區塊 */}
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600">配方預估總成本</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{totalCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
|
||||||
|
<span className="text-sm font-medium text-gray-700">單位生產成本
|
||||||
|
<span className="text-xs text-gray-500 ml-1">(共 {Number(data.yield_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} 份)</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-md font-bold text-primary-main">{unitCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
* 配方管理主頁面
|
* 配方管理主頁面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Plus, Search, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
|
import { Plus, Search, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
|
||||||
|
import { debounce } from "lodash";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router, Link } from "@inertiajs/react";
|
import { Head, router, Link } from "@inertiajs/react";
|
||||||
@@ -39,6 +40,8 @@ interface Recipe {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
estimated_total_cost?: number;
|
||||||
|
estimated_unit_cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -71,17 +74,25 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
setPerPage(filters.per_page || "10");
|
setPerPage(filters.per_page || "10");
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
const handleFilter = () => {
|
const debouncedFilter = useCallback(
|
||||||
router.get(
|
debounce((params: any) => {
|
||||||
route('recipes.index'),
|
router.get(route("recipes.index"), params, {
|
||||||
{
|
preserveState: true,
|
||||||
search,
|
replace: true,
|
||||||
per_page: perPage,
|
preserveScroll: true,
|
||||||
},
|
});
|
||||||
{ preserveState: true, replace: true, preserveScroll: true }
|
}, 300),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
|
const handleSearchChange = (term: string) => {
|
||||||
|
setSearch(term);
|
||||||
|
debouncedFilter({
|
||||||
|
...filters,
|
||||||
|
search: term,
|
||||||
|
per_page: perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handlePerPageChange = (value: string) => {
|
const handlePerPageChange = (value: string) => {
|
||||||
setPerPage(value);
|
setPerPage(value);
|
||||||
@@ -138,16 +149,12 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="搜尋配方代號、名稱、產品名稱..."
|
placeholder="搜尋配方代號、名稱、產品名稱..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
className="pl-10 pr-10 h-9"
|
className="pl-10 pr-10 h-9"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => handleSearchChange("")}
|
||||||
setSearch("");
|
|
||||||
router.get(route('recipes.index'), { ...filters, search: "" }, { preserveState: true, replace: true, preserveScroll: true });
|
|
||||||
}}
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" /> {/* Using Trash2/X as clear icon, need to check imports. Inventory used X. */}
|
<Trash2 className="h-4 w-4" /> {/* Using Trash2/X as clear icon, need to check imports. Inventory used X. */}
|
||||||
@@ -157,15 +164,6 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="button-outlined-primary"
|
|
||||||
onClick={handleFilter}
|
|
||||||
>
|
|
||||||
<Search className="w-4 h-4 mr-2" />
|
|
||||||
搜尋
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Can permission="recipes.create">
|
<Can permission="recipes.create">
|
||||||
<Link href={route('recipes.create')}>
|
<Link href={route('recipes.create')}>
|
||||||
<Button className="button-filled-primary">
|
<Button className="button-filled-primary">
|
||||||
@@ -187,6 +185,8 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
<TableHead>配方名稱</TableHead>
|
<TableHead>配方名稱</TableHead>
|
||||||
<TableHead>對應成品</TableHead>
|
<TableHead>對應成品</TableHead>
|
||||||
<TableHead className="text-right">標準產量</TableHead>
|
<TableHead className="text-right">標準產量</TableHead>
|
||||||
|
<TableHead className="text-right">預估單位成本</TableHead>
|
||||||
|
<TableHead className="text-right">預估總成本</TableHead>
|
||||||
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
||||||
<TableHead className="w-[150px]">更新時間</TableHead>
|
<TableHead className="w-[150px]">更新時間</TableHead>
|
||||||
<TableHead className="text-center w-[150px]">操作</TableHead>
|
<TableHead className="text-center w-[150px]">操作</TableHead>
|
||||||
@@ -195,7 +195,7 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{recipes.data.length === 0 ? (
|
{recipes.data.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="h-32 text-center text-gray-500">
|
<TableCell colSpan={9} className="h-32 text-center text-gray-500">
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<BookOpen className="h-10 w-10 text-gray-300" />
|
<BookOpen className="h-10 w-10 text-gray-300" />
|
||||||
<p>尚無配方資料</p>
|
<p>尚無配方資料</p>
|
||||||
@@ -227,7 +227,13 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
) : '-'}
|
) : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-medium">
|
<TableCell className="text-right font-medium">
|
||||||
{recipe.yield_quantity}
|
{Number(recipe.yield_quantity).toLocaleString(undefined, { maximumFractionDigits: 4 })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium text-primary-main">
|
||||||
|
{Number(recipe.estimated_unit_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium text-gray-700">
|
||||||
|
{Number(recipe.estimated_total_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{recipe.is_active ? (
|
{recipe.is_active ? (
|
||||||
@@ -338,6 +344,6 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
|||||||
isLoading={isViewLoading}
|
isLoading={isViewLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface ProductionOrderItem {
|
|||||||
origin_country: string | null;
|
origin_country: string | null;
|
||||||
product: { id: number; name: string; code: string } | null;
|
product: { id: number; name: string; code: string } | null;
|
||||||
warehouse?: { id: number; name: string } | null;
|
warehouse?: { id: number; name: string } | null;
|
||||||
|
unit_cost?: number;
|
||||||
source_purchase_order?: {
|
source_purchase_order?: {
|
||||||
id: number;
|
id: number;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -55,6 +56,8 @@ interface ProductionOrder {
|
|||||||
output_batch_number: string;
|
output_batch_number: string;
|
||||||
output_box_count: string | null;
|
output_box_count: string | null;
|
||||||
output_quantity: number;
|
output_quantity: number;
|
||||||
|
actual_output_quantity: number | null;
|
||||||
|
loss_reason: string | null;
|
||||||
production_date: string;
|
production_date: string;
|
||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
status: ProductionOrderStatus;
|
status: ProductionOrderStatus;
|
||||||
@@ -87,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
warehouseId?: number;
|
warehouseId?: number;
|
||||||
batchNumber?: string;
|
batchNumber?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
|
actualOutputQuantity?: number;
|
||||||
|
lossReason?: string;
|
||||||
}) => {
|
}) => {
|
||||||
router.patch(route('production-orders.update-status', productionOrder.id), {
|
router.patch(route('production-orders.update-status', productionOrder.id), {
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
warehouse_id: extraData?.warehouseId,
|
warehouse_id: extraData?.warehouseId,
|
||||||
output_batch_number: extraData?.batchNumber,
|
output_batch_number: extraData?.batchNumber,
|
||||||
expiry_date: extraData?.expiryDate,
|
expiry_date: extraData?.expiryDate,
|
||||||
|
actual_output_quantity: extraData?.actualOutputQuantity,
|
||||||
|
loss_reason: extraData?.lossReason,
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIsWarehouseModalOpen(false);
|
setIsWarehouseModalOpen(false);
|
||||||
@@ -109,6 +116,13 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
const canCancel = hasPermission('production_orders.cancel');
|
const canCancel = hasPermission('production_orders.cancel');
|
||||||
const canEdit = hasPermission('production_orders.edit');
|
const canEdit = hasPermission('production_orders.edit');
|
||||||
|
|
||||||
|
// 計算總預估成本
|
||||||
|
const totalEstimatedCost = productionOrder.items.reduce((sum, item) => {
|
||||||
|
const qty = Number(item.quantity_used) || 0;
|
||||||
|
const cost = Number(item.inventory?.unit_cost) || 0;
|
||||||
|
return sum + (qty * cost);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}>
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}>
|
||||||
<Head title={`生產單 ${productionOrder.code}`} />
|
<Head title={`生產單 ${productionOrder.code}`} />
|
||||||
@@ -121,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
processing={processing}
|
processing={processing}
|
||||||
productCode={productionOrder.product?.code}
|
productCode={productionOrder.product?.code}
|
||||||
productId={productionOrder.product?.id}
|
productId={productionOrder.product?.id}
|
||||||
|
outputQuantity={Number(productionOrder.output_quantity)}
|
||||||
|
unitName={productionOrder.product?.base_unit?.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||||
@@ -268,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計/實際產量</p>
|
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計產量</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<p className="font-bold text-grey-0 text-xl">
|
<p className="font-bold text-grey-0 text-xl">
|
||||||
{formatQuantity(productionOrder.output_quantity)}
|
{formatQuantity(productionOrder.output_quantity)}
|
||||||
@@ -281,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 實際產量與耗損(僅完成狀態顯示) */}
|
||||||
|
{productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">實際產量</p>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<p className="font-bold text-grey-0 text-xl">
|
||||||
|
{formatQuantity(productionOrder.actual_output_quantity)}
|
||||||
|
</p>
|
||||||
|
{productionOrder.product?.base_unit?.name && (
|
||||||
|
<span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
|
||||||
|
)}
|
||||||
|
{Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold border border-orange-200">
|
||||||
|
耗損 {formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{productionOrder.loss_reason && (
|
||||||
|
<p className="text-xs text-orange-600 mt-1">原因:{productionOrder.loss_reason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">入庫倉庫</p>
|
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">入庫倉庫</p>
|
||||||
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
|
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
|
||||||
@@ -368,6 +406,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使用批號</TableHead>
|
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使用批號</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-center">來源國家</TableHead>
|
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-center">來源國家</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使用數量</TableHead>
|
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使用數量</TableHead>
|
||||||
|
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-right">預估單價</TableHead>
|
||||||
|
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-right">成本小計</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">來源單據</TableHead>
|
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">來源單據</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -404,6 +444,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="px-6 py-5 text-right">
|
||||||
|
<div className="text-grey-0 font-medium">
|
||||||
|
{Number(item.inventory?.unit_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-6 py-5 text-right">
|
||||||
|
<div className="font-bold text-grey-900">
|
||||||
|
{(Number(item.quantity_used) * Number(item.inventory?.unit_cost || 0)).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="px-6 py-5">
|
<TableCell className="px-6 py-5">
|
||||||
{item.inventory?.source_purchase_order ? (
|
{item.inventory?.source_purchase_order ? (
|
||||||
<div className="group flex flex-col">
|
<div className="group flex flex-col">
|
||||||
@@ -428,6 +478,22 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<div className="bg-gray-50 p-6 border-t border-grey-4 flex justify-end">
|
||||||
|
<div className="min-w-[300px]">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600">生產單總預估成本</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
|
||||||
|
<span className="text-sm font-medium text-gray-700">預估單位生產成本
|
||||||
|
<span className="text-xs text-gray-500 ml-1">(共 {Number(productionOrder.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} 份)</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-md font-bold text-primary-main">
|
||||||
|
{(productionOrder.output_quantity > 0 ? totalEstimatedCost / productionOrder.output_quantity : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user