Compare commits
57 Commits
feature/mo
...
16967fc25d
| Author | SHA1 | Date | |
|---|---|---|---|
| 16967fc25d | |||
| 29842510c4 | |||
| 19216f5846 | |||
| bd999c7bb6 | |||
| 15aaa039e4 | |||
| 27626e6aa8 | |||
| a160e3f15f | |||
| d671c08338 | |||
| 0185843c62 | |||
| be5c121146 | |||
| f87310e707 | |||
| b0192e9b66 | |||
| 8a34aae312 | |||
| 6204f0d915 | |||
| df3db38dd4 | |||
| 75c634ffe4 | |||
| 1748eb007e | |||
| 313b95ceb9 | |||
| 5e897e4197 | |||
| 71458dd976 | |||
| 36ef411975 | |||
| bb78a432f5 | |||
| 0d720f3515 | |||
| 2e71a1cb29 | |||
| 746eeb6f01 | |||
| 7619dc24f7 | |||
| 2efaded77b | |||
| a31c8d6052 | |||
| 56e30a85bb | |||
| 46753cc3bc | |||
| 7f726e80bd | |||
| 8bc95db43d | |||
| 95a1763d04 | |||
| 90cb7a82de | |||
| bbb2c4c4a3 | |||
| 8cb95e1a56 | |||
| fc59c86305 | |||
| b613cdb796 | |||
| b1745555cc | |||
| 1833ca192d | |||
| e5edad4fd0 | |||
| 852370cfe0 | |||
| 965418077b | |||
| c3af92c85c | |||
| cca49b5fe8 | |||
| d4cef2cd84 | |||
| 4c959efc8b | |||
| 95d8dc2e84 | |||
| a7c445bd3f | |||
| 293358df62 | |||
| 1ed3d6a29d | |||
| 646435f87a | |||
| f10c31abd0 | |||
| 046e0a028b | |||
| ce0a7b3409 | |||
| 084bbc9f53 | |||
| 3af4a1e298 |
@@ -50,7 +50,16 @@ trigger: always_on
|
||||
* Routes: `kebab-case` (小寫橫線分隔)
|
||||
* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||
|
||||
## 6. AI 協作規則 (給 Antigravity AI)
|
||||
## 6. 嚴格模組化通訊規範 (Strict Modular Communication)
|
||||
為了確保系統的可維護性與獨立性,所有模組必須遵守以下「實體解耦」規範:
|
||||
|
||||
* **禁止跨模組 Eloquent 關聯**:禁止在 Model 中定義指向其他模組的 `belongsTo`, `hasMany` 等關聯。
|
||||
* **介面化通訊 (Contracts)**:模組間的資料交換與功能調用必須透過 `app/Modules/{ModuleName}/Contracts/` 下定義的介面進行。
|
||||
* **禁止跨模組 Model 引用**:Controller 與 Service 禁止 `use` 其他模組的 Model (除非是該模組自身的 Contracts)。
|
||||
* **手動資料水和 (Manual Hydration)**:若頁面需要顯示跨模組資料(例:訂單顯示使用者名稱),Controller 應透過 Service 獲取基本資料,再手動組合成前端所需的 JSON/Props 結構。
|
||||
* **資料一致性**:跨模組的資料操作應由各模組的 Service 處理其內部的 transaction 完整性。
|
||||
|
||||
## 7. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
@@ -58,7 +67,17 @@ trigger: always_on
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 7. 運行機制 (Docker / Sail)
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||
* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。
|
||||
* **指令執行**:
|
||||
* **Seeders**: 必須執行 `./vendor/bin/sail php artisan tenants:run db:seed` 以確保所有租戶均獲得更新。
|
||||
* **Tinker**: 檢查租戶資料時應使用 `./vendor/bin/sail php artisan tenants:run tinker`。
|
||||
* **Migrations**: 租戶相關的 Schema 異動應放在 `database/migrations/tenant/` 並執行 `./vendor/bin/sail artisan tenants:migrate`。
|
||||
|
||||
## 9. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
|
||||
@@ -123,8 +123,8 @@ tooltip
|
||||
// ✅ 成功操作
|
||||
<Button className="button-filled-success">確認</Button>
|
||||
|
||||
// ✅ 資訊操作
|
||||
<Button className="button-filled-info">查看詳情</Button>
|
||||
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
|
||||
<Button className="button-filled-info">系統資訊</Button>
|
||||
|
||||
// ✅ 警告操作
|
||||
<Button className="button-filled-warning">警告</Button>
|
||||
@@ -177,6 +177,23 @@ tooltip
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列檢視按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.view">
|
||||
<Link href={route('resource.show', item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="檢視"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列編輯按鈕
|
||||
|
||||
```tsx
|
||||
@@ -230,6 +247,30 @@ tooltip
|
||||
</Can>
|
||||
```
|
||||
|
||||
### 3.4 返回按鈕規範
|
||||
|
||||
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
|
||||
|
||||
**樣式規格**:
|
||||
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
|
||||
- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"`
|
||||
- **圖標**:`<ArrowLeft className="h-4 w-4" />`
|
||||
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
|
||||
|
||||
```tsx
|
||||
<div className="mb-6">
|
||||
<Link href={route('resource.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 圖標規範
|
||||
@@ -426,23 +467,27 @@ const handleSort = (field: string) => {
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
// 在表格下方
|
||||
// 在表格下方(底部工具列)
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[80px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[90px] h-8" // ✅ 統一使用 90px 寬度,避免「100」顯示不全
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
{/* 總筆數顯示:統一放在每頁顯示右側,使用 text-gray-500 */}
|
||||
<span className="text-sm text-gray-500">共 {data.total} 筆紀錄</span>
|
||||
</div>
|
||||
<Pagination links={data.links} />
|
||||
</div>
|
||||
|
||||
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal file
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範
|
||||
|
||||
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
|
||||
|
||||
## 1. 後端實作標準 (Backend)
|
||||
|
||||
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
|
||||
|
||||
### 1.1 啟用 Activity Log
|
||||
|
||||
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
|
||||
|
||||
```php
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
|
||||
->dontSubmitEmptyLogs(); // 若無變動則不記錄
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 手動記錄 (Manual Logging)
|
||||
|
||||
若需在 Controller 手動記錄(例如需客製化邏輯),**必須**自行實作變動過濾,不可直接儲存所有屬性。
|
||||
|
||||
**錯誤範例 (Do NOT do this):**
|
||||
```php
|
||||
// ❌ 錯誤:這會導致每次更新都記錄所有欄位,即使它們沒變
|
||||
activity()
|
||||
->withProperties(['attributes' => $newAttributes, 'old' => $oldAttributes])
|
||||
->log('updated');
|
||||
```
|
||||
|
||||
**正確範例 (Do this):**
|
||||
```php
|
||||
// ✅ 正確:自行比對差異,只存變動值
|
||||
$changedAttributes = [];
|
||||
$changedOldAttributes = [];
|
||||
|
||||
foreach ($newAttributes as $key => $value) {
|
||||
if ($value != ($oldAttributes[$key] ?? null)) {
|
||||
$changedAttributes[$key] = $value;
|
||||
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($changedAttributes)) {
|
||||
activity()
|
||||
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
|
||||
->log('updated');
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 快照策略 (Snapshot Strategy)
|
||||
|
||||
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊(如名稱、代號、類別名稱)。
|
||||
|
||||
**主要方式:使用 `tapActivity` (推薦)**
|
||||
|
||||
```php
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
|
||||
$snapshot['category_name'] = $this->category ? $this->category->name : null;
|
||||
$snapshot['po_number'] = $this->code; // 儲存單號
|
||||
|
||||
// 保存自身名稱 (Context)
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 顯示名稱映射 (UI Mapping)
|
||||
|
||||
### 2.1 對象名稱映射 (Mapping)
|
||||
|
||||
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
|
||||
|
||||
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
|
||||
|
||||
```php
|
||||
protected function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 欄位名稱中文化 (Field Translation)
|
||||
|
||||
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
|
||||
```typescript
|
||||
const fieldLabels: Record<string, string> = {
|
||||
// ... 既有欄位
|
||||
'transaction_date': '費用日期',
|
||||
'category': '費用類別',
|
||||
'amount': '金額',
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 前端顯示邏輯 (Frontend)
|
||||
|
||||
### 3.1 列表描述生成 (Description Generation)
|
||||
|
||||
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。
|
||||
|
||||
若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
|
||||
|
||||
```typescript
|
||||
const nameParams = [
|
||||
'po_number', 'name', 'code',
|
||||
'category_name',
|
||||
'category' // ✅ 確保加入此欄位,前端才能抓到 $snapshot['category']
|
||||
];
|
||||
```
|
||||
|
||||
### 3.2 詳情過濾邏輯
|
||||
|
||||
前端 `ActivityDetailDialog` 已內建智慧過濾邏輯:
|
||||
- **Created**: 顯示初始化欄位。
|
||||
- **Updated**: **僅顯示有變動的欄位** (由 `isChanged` 判斷)。
|
||||
- **Deleted**: 顯示刪除前的完整資料。
|
||||
|
||||
開發者僅需確保傳入的 `attributes` 與 `old` 資料結構正確,過濾邏輯會自動運作。
|
||||
|
||||
## 檢核清單
|
||||
|
||||
- [ ] **Backend**: Model 是否已設定 `logOnlyDirty` 或手動實作過濾?
|
||||
- [ ] **Backend**: 是否已透過 `tapActivity` 或手動方式記錄 Snapshot(關鍵名稱)?
|
||||
- [ ] **Backend**: 是否已在 `ActivityLogController` 加入 Model 中文名稱映射?
|
||||
- [ ] **Frontend**: 是否已在 `ActivityDetailDialog` 加入欄位中文翻譯?
|
||||
- [ ] **Frontend**: 若使用特殊識別欄位,是否已加入 `LogTable` 的 `nameParams`?
|
||||
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal file
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: 權限管理與實作規範
|
||||
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||
---
|
||||
|
||||
# 權限管理與實作規範
|
||||
|
||||
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
|
||||
|
||||
## 1. 定義權限 (Backend)
|
||||
|
||||
所有權限皆定義於 `database/seeders/PermissionSeeder.php`。
|
||||
|
||||
### 步驟:
|
||||
|
||||
1. 開啟 `database/seeders/PermissionSeeder.php`。
|
||||
2. 在 `$permissions` 陣列中新增功能對應的權限字串。
|
||||
* **命名慣例**:`{resource}.{action}` (例如:`system.view_logs`, `products.create`)
|
||||
* 常用動作:`view`, `create`, `edit`, `delete`, `publish`, `export`
|
||||
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
|
||||
* `super-admin`:通常擁有所有權限(程式碼中 `Permission::all()` 自動涵蓋,無需手動新增)。
|
||||
* `admin`:通常擁有大部分權限。
|
||||
* 其他角色 (`warehouse-manager`, `purchaser`, `viewer`):依業務邏輯分配。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 1. 新增權限字串
|
||||
$permissions = [
|
||||
// ... 現有權限
|
||||
'system.view_logs', // 新增:檢視系統日誌
|
||||
];
|
||||
|
||||
// ...
|
||||
|
||||
// 2. 分配給角色
|
||||
$admin->givePermissionTo([
|
||||
// ... 現有權限
|
||||
'system.view_logs',
|
||||
]);
|
||||
```
|
||||
|
||||
## 2. 套用資料庫變更
|
||||
|
||||
修改 Seeder 後,必須重新執行 Seeder 以將權限寫入資料庫。
|
||||
|
||||
```bash
|
||||
# 對於所有租戶執行 Seeder (開發環境)
|
||||
php artisan tenants:seed --class=PermissionSeeder
|
||||
```
|
||||
|
||||
## 3. 路由保護 (Backend Middleware)
|
||||
|
||||
在 `routes/web.php` 中,使用 `permission:{name}` middleware 保護路由。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 單一權限保護
|
||||
Route::get('/logs', [LogController::class, 'index'])
|
||||
->middleware('permission:system.view_logs')
|
||||
->name('logs.index');
|
||||
|
||||
// 路由群組保護
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
// ...
|
||||
});
|
||||
|
||||
// 多重權限 (OR 邏輯:有其一即可)
|
||||
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## 4. 前端權限判斷 (React Component)
|
||||
|
||||
使用自訂 Hook `usePermission` 來控制 UI 元素的顯示(例如:隱藏沒有權限的按鈕)。
|
||||
|
||||
### 引入 Hook:
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
```
|
||||
|
||||
### 使用方式:
|
||||
|
||||
```tsx
|
||||
export default function ProductIndex() {
|
||||
const { can } = usePermission();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>商品列表</h1>
|
||||
|
||||
{/* 只有擁有 create 權限才顯示按鈕 */}
|
||||
{can('products.create') && (
|
||||
<Button>新增商品</Button>
|
||||
)}
|
||||
|
||||
{/* 組合判斷 */}
|
||||
{can('products.edit') && <EditButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 權限 Hook 介面說明:
|
||||
|
||||
- `can(permission: string)`: 檢查當前使用者是否擁有指定權限。
|
||||
- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
|
||||
- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
|
||||
|
||||
## 5. 配置權限群組名稱 (Backend UI Config)
|
||||
|
||||
為了讓新權限在「角色與權限」管理介面中顯示正確的中文分組標題,需修改 Controller 設定。
|
||||
|
||||
### 步驟:
|
||||
|
||||
1. 開啟 `app/Http/Controllers/Admin/RoleController.php`。
|
||||
2. 找到 `getGroupedPermissions` 方法。
|
||||
3. 在 `$groupDefinitions` 陣列中,新增 `{resource}` 對應的中文名稱。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
$groupDefinitions = [
|
||||
'products' => '商品資料管理',
|
||||
// ...
|
||||
'utility_fees' => '公共事業費管理', // 新增此行
|
||||
];
|
||||
```
|
||||
|
||||
## 檢核清單
|
||||
|
||||
- [ ] `PermissionSeeder.php` 已新增權限字串。
|
||||
- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
|
||||
- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
|
||||
- [ ] `RoleController.php` 已新增權限群組的中文名稱映射。
|
||||
- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
|
||||
- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。
|
||||
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal file
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal file
@@ -0,0 +1,990 @@
|
||||
---
|
||||
name: 客戶端後台 UI 統一規範
|
||||
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
|
||||
---
|
||||
|
||||
# 客戶端後台 UI 統一規範
|
||||
|
||||
## 概述
|
||||
|
||||
本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。
|
||||
|
||||
> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
|
||||
|
||||
## 核心原則
|
||||
|
||||
1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件
|
||||
2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別
|
||||
3. **統一的圖標系統**:全面使用 `lucide-react` 圖標
|
||||
4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構
|
||||
5. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
|
||||
|
||||
---
|
||||
|
||||
## 1. 專案結構
|
||||
|
||||
### 1.1 關鍵目錄
|
||||
|
||||
```
|
||||
resources/
|
||||
├── css/
|
||||
│ └── app.css # 全域樣式與設計 Token
|
||||
├── js/
|
||||
│ ├── Components/
|
||||
│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui)
|
||||
│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等)
|
||||
│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll)
|
||||
│ ├── Layouts/
|
||||
│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用
|
||||
│ │ └── LandlordLayout.tsx # 中央管理後台佈局
|
||||
│ └── Pages/ # 頁面元件
|
||||
```
|
||||
|
||||
### 1.2 可用 UI 元件清單
|
||||
|
||||
```
|
||||
accordion, alert, alert-dialog, avatar, badge, breadcrumb, button,
|
||||
calendar, card, carousel, chart, checkbox, collapsible, command,
|
||||
context-menu, dialog, drawer, dropdown-menu, form, hover-card,
|
||||
input, input-otp, label, menubar, navigation-menu, pagination,
|
||||
popover, progress, radio-group, resizable, scroll-area,
|
||||
searchable-select, select, separator, sheet, sidebar, skeleton,
|
||||
slider, sonner, switch, table, tabs, textarea, toggle, toggle-group,
|
||||
tooltip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 色彩系統
|
||||
|
||||
### 2.1 主題色 (Primary) - **動態租戶品牌色**
|
||||
|
||||
> **注意**:主題色會根據租戶設定(Branding)動態改變,**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。
|
||||
> 請務必使用 Tailwind Utility Class 或 CSS 變數。
|
||||
|
||||
| Tailwind Class | CSS Variable | 說明 |
|
||||
|----------------|--------------|------|
|
||||
| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 |
|
||||
| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 |
|
||||
| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 |
|
||||
| `*-primary-lightest` | `--primary-lightest` | **最淺色**:系統自動計算,用於背景底色、Active 狀態 |
|
||||
|
||||
**運作機制**:
|
||||
`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。
|
||||
|
||||
```tsx
|
||||
// ✅ 正確:使用 Tailwind Class
|
||||
<div className="text-primary-main">...</div>
|
||||
|
||||
// ✅ 正確:使用 CSS 變數 (自定義樣式時)
|
||||
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
|
||||
|
||||
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
|
||||
<div className="text-[#01ab83]">...</div>
|
||||
```
|
||||
|
||||
### 2.2 灰階 (Grey Scale)
|
||||
|
||||
```css
|
||||
--grey-0: #1a1a1a; /* 深黑 - 標題文字 */
|
||||
--grey-1: #4a4a4a; /* 深灰 - 主要內文 */
|
||||
--grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */
|
||||
--grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */
|
||||
--grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */
|
||||
--grey-5: #fff; /* 白色 - 背景、按鈕文字 */
|
||||
```
|
||||
|
||||
### 2.3 狀態色 (State Colors)
|
||||
|
||||
```css
|
||||
--other-success: #01ab83; /* 成功 - 同主題色 */
|
||||
--other-error: #dc2626; /* 錯誤 - 刪除、警示 */
|
||||
--other-warning: #f59e0b; /* 警告 - 提醒、注意 */
|
||||
--other-info: #3b82f6; /* 資訊 - 說明、提示 */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 按鈕規範
|
||||
|
||||
### 3.1 按鈕樣式類別
|
||||
|
||||
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別:
|
||||
|
||||
#### Filled 按鈕(實心按鈕)— 用於主要操作
|
||||
|
||||
```tsx
|
||||
// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增項目
|
||||
</Button>
|
||||
|
||||
// ✅ 成功操作
|
||||
<Button className="button-filled-success">確認</Button>
|
||||
|
||||
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
|
||||
<Button className="button-filled-info">系統資訊</Button>
|
||||
|
||||
// ✅ 警告操作
|
||||
<Button className="button-filled-warning">警告</Button>
|
||||
|
||||
// ✅ 錯誤/刪除操作(AlertDialog 內確認按鈕)
|
||||
<Button className="button-filled-error">刪除</Button>
|
||||
```
|
||||
|
||||
#### Outlined 按鈕(邊框按鈕)— 用於次要操作
|
||||
|
||||
```tsx
|
||||
// ✅ 編輯按鈕(表格操作列)
|
||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
// ✅ 刪除按鈕(表格操作列)
|
||||
<Button variant="outline" size="sm" className="button-outlined-error">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### Text 按鈕(文字按鈕)
|
||||
|
||||
```tsx
|
||||
<Button className="button-text-primary">查看更多</Button>
|
||||
```
|
||||
|
||||
### 3.2 按鈕大小
|
||||
|
||||
| Size | 高度 | 使用情境 |
|
||||
|------|------|----------|
|
||||
| `size="sm"` | h-8 | 表格操作列、緊湊佈局 |
|
||||
| `size="default"` | h-9 | 一般操作、表單提交 |
|
||||
| `size="lg"` | h-10 | 主要 CTA、頁面主操作 |
|
||||
| `size="icon"` | 9×9 | 純圖標按鈕 |
|
||||
|
||||
### 3.3 常見操作按鈕模式
|
||||
|
||||
#### 頁面頂部新增按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.create">
|
||||
<Link href={route('resource.create')}>
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增XXX
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列檢視按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.view">
|
||||
<Link href={route('resource.show', item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="檢視"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列編輯按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.edit">
|
||||
<Link href={route('resource.edit', item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="編輯"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列刪除按鈕(帶確認對話框)
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.delete">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除「{item.name}」嗎?此操作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
```
|
||||
|
||||
### 3.4 返回按鈕規範
|
||||
|
||||
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
|
||||
|
||||
**樣式規格**:
|
||||
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
|
||||
- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"`
|
||||
- **圖標**:`<ArrowLeft className="h-4 w-4" />`
|
||||
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
|
||||
|
||||
```tsx
|
||||
<div className="mb-6">
|
||||
<Link href={route('resource.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 圖標規範
|
||||
|
||||
### 4.1 統一使用 lucide-react
|
||||
|
||||
**統一使用 `lucide-react`**,禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。
|
||||
|
||||
### 4.2 圖標尺寸標準
|
||||
|
||||
| 尺寸 | 類別 | 使用情境 |
|
||||
|------|------|----------|
|
||||
| 小型 | `h-3 w-3` | Badge 內、小文字旁 |
|
||||
| 標準 | `h-4 w-4` | 按鈕內、表格操作 |
|
||||
| 標題 | `h-5 w-5` | 側邊欄選單 |
|
||||
| 大型 | `h-6 w-6` | 頁面標題 |
|
||||
|
||||
### 4.3 常用操作圖標映射
|
||||
|
||||
| 操作 | 圖標組件 | 使用情境 |
|
||||
|------|----------|----------|
|
||||
| 新增 | `<Plus />` | 新增按鈕 |
|
||||
| 編輯 | `<Pencil />` | 編輯按鈕 |
|
||||
| 刪除 | `<Trash2 />` | 刪除按鈕 |
|
||||
| 查看 | `<Eye />` | 查看詳情 |
|
||||
| 搜尋 | `<Search />` | 搜尋欄位 |
|
||||
| 篩選 | `<Filter />` | 篩選功能 |
|
||||
| 下載 | `<Download />` | 下載/匯出 |
|
||||
| 上傳 | `<Upload />` | 上傳/匯入 |
|
||||
| 設定 | `<Settings />` | 設定功能 |
|
||||
| 複製 | `<Copy />` | 複製內容 |
|
||||
| 郵件 | `<Mail />` | Email 顯示 |
|
||||
| 使用者 | `<Users />`, `<User />` | 使用者管理 |
|
||||
| 權限 | `<Shield />` | 角色/權限 |
|
||||
| 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 |
|
||||
| 儀表板 | `<LayoutDashboard />` | 首頁/總覽 |
|
||||
| 商品 | `<Package />` | 商品管理 |
|
||||
| 倉庫 | `<Warehouse />` | 倉庫管理 |
|
||||
| 廠商 | `<Truck />`, `<Contact2 />` | 廠商管理 |
|
||||
| 採購 | `<ShoppingCart />` | 採購管理 |
|
||||
|
||||
### 4.4 圖標使用範例
|
||||
|
||||
```tsx
|
||||
import { Plus, Pencil, Trash2, Users } from 'lucide-react';
|
||||
|
||||
// 頁面標題
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||
使用者管理
|
||||
</h1>
|
||||
|
||||
// 按鈕內圖標(圖標在左,帶文字)
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增使用者
|
||||
</Button>
|
||||
|
||||
// 純圖標按鈕(表格操作列)
|
||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 表格規範
|
||||
|
||||
### 5.1 表格容器
|
||||
|
||||
```tsx
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
{/* 表格內容 */}
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.2 表格標題列
|
||||
|
||||
```tsx
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>名稱</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
```
|
||||
|
||||
**關鍵要點**:
|
||||
- 使用 `bg-gray-50` 背景色
|
||||
- 序號欄位固定寬度 `w-[50px]` 並置中
|
||||
- 操作欄位置中顯示
|
||||
|
||||
### 5.3 表格主體
|
||||
|
||||
```tsx
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
|
||||
無符合條件的資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{startIndex + index}
|
||||
</TableCell>
|
||||
{/* 其他欄位 */}
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{/* 操作按鈕 */}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
```
|
||||
|
||||
**關鍵要點**:
|
||||
- 空狀態訊息使用置中、灰色文字
|
||||
- 序號欄使用 `text-gray-500 font-medium text-center`
|
||||
- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕
|
||||
|
||||
### 5.4 欄位排序規範
|
||||
|
||||
當表格需要支援排序時,請遵循以下模式:
|
||||
|
||||
1. **圖標邏輯**:
|
||||
* 未排序:`ArrowUpDown` (class: `text-muted-foreground`)
|
||||
* 升冪 (asc):`ArrowUp` (class: `text-primary`)
|
||||
* 降冪 (desc):`ArrowDown` (class: `text-primary`)
|
||||
2. **結構**:在 `TableHead` 內使用 `button` 元素。
|
||||
3. **後端配合**:後端 Controller **必須** 處理 `sort_by` 與 `sort_order` 參數。
|
||||
|
||||
```tsx
|
||||
// 1. 定義 Helper Component (在元件內部)
|
||||
const SortIcon = ({ field }: { field: string }) => {
|
||||
if (filters.sort_by !== field) {
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
|
||||
}
|
||||
if (filters.sort_order === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
|
||||
}
|
||||
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
|
||||
};
|
||||
|
||||
// 2. 表格標題應用
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() => handleSort('created_at')}
|
||||
className="flex items-center hover:text-gray-900"
|
||||
>
|
||||
建立時間 <SortIcon field="created_at" />
|
||||
</button>
|
||||
</TableHead>
|
||||
|
||||
// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序)
|
||||
const handleSort = (field: string) => {
|
||||
let newSortBy: string | undefined = field;
|
||||
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||
|
||||
if (filters.sort_by === field) {
|
||||
if (filters.sort_order === 'asc') {
|
||||
newSortOrder = 'desc';
|
||||
} else {
|
||||
// desc -> reset (回到預設排序)
|
||||
newSortBy = undefined;
|
||||
newSortOrder = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
route(route().current()!),
|
||||
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 分頁規範
|
||||
|
||||
### 6.1 統一分頁元件
|
||||
|
||||
使用 `@/Components/shared/Pagination` 元件:
|
||||
|
||||
```tsx
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
// 在表格下方
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[80px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<Pagination links={data.links} />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6.2 每頁筆數狀態管理
|
||||
|
||||
```tsx
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route('resource.index'),
|
||||
{ per_page: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Badge 與狀態顯示
|
||||
|
||||
### 7.1 基本 Badge
|
||||
|
||||
```tsx
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
// Outline 樣式(最常用)
|
||||
<Badge variant="outline">{item.category?.name || '-'}</Badge>
|
||||
|
||||
// 預設樣式(主題色背景)
|
||||
<Badge variant="default">啟用中</Badge>
|
||||
|
||||
// 錯誤樣式
|
||||
<Badge variant="destructive">停用</Badge>
|
||||
```
|
||||
|
||||
### 7.2 角色顯示(特殊樣式)
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.roles.map(role => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-md border",
|
||||
role.name === 'super-admin'
|
||||
? "bg-purple-50 border-purple-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{role.name === 'super-admin' && <Shield className="h-3.5 w-3.5 text-purple-600" />}
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
role.name === 'super-admin' ? "text-purple-700" : "text-gray-900"
|
||||
)}>
|
||||
{role.display_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 頁面佈局規範
|
||||
|
||||
### 8.1 頁面結構
|
||||
|
||||
```tsx
|
||||
export default function ResourceIndex() {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '分類名稱', href: '#' },
|
||||
{ label: '頁面名稱', href: route('resource.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="頁面標題" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面頭部 */}
|
||||
{/* 主要內容 */}
|
||||
{/* 分頁元件 */}
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 標準頁面頭部
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<IconComponent className="h-6 w-6 text-[#01ab83]" />
|
||||
頁面標題
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
頁面說明文字
|
||||
</p>
|
||||
</div>
|
||||
<Can permission="resource.create">
|
||||
<Link href={route('resource.create')}>
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增項目
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 權限控制規範
|
||||
|
||||
### 9.1 使用 Can 元件
|
||||
|
||||
**所有**涉及權限的 UI 元素都必須使用 `<Can>` 元件包裹:
|
||||
|
||||
```tsx
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
|
||||
<Can permission="resource.create">
|
||||
{/* 新增按鈕 */}
|
||||
</Can>
|
||||
|
||||
<Can permission="resource.edit">
|
||||
{/* 編輯按鈕 */}
|
||||
</Can>
|
||||
|
||||
<Can permission="resource.delete">
|
||||
{/* 刪除按鈕 */}
|
||||
</Can>
|
||||
```
|
||||
|
||||
### 9.2 權限命名規範
|
||||
|
||||
遵循 `resource.action` 格式:
|
||||
|
||||
- `resource.view`:查看列表/詳情
|
||||
- `resource.create`:新增
|
||||
- `resource.edit`:編輯
|
||||
- `resource.delete`:刪除
|
||||
|
||||
### 9.3 多權限判斷
|
||||
|
||||
```tsx
|
||||
// 滿足任一權限即可
|
||||
<Can permission={['products.edit', 'products.delete']}>
|
||||
<div>管理操作</div>
|
||||
</Can>
|
||||
|
||||
// 必須滿足所有權限
|
||||
import { CanAll } from "@/Components/Permission/Can";
|
||||
|
||||
<CanAll permissions={['products.edit', 'products.delete']}>
|
||||
<button>完整管理</button>
|
||||
</CanAll>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 通知訊息規範
|
||||
|
||||
### 10.1 使用 Toast 通知
|
||||
|
||||
使用 `sonner` 的 `toast` 進行通知:
|
||||
|
||||
```tsx
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 成功訊息
|
||||
toast.success('操作成功');
|
||||
|
||||
// 錯誤訊息
|
||||
toast.error('操作失敗');
|
||||
|
||||
// 資訊訊息
|
||||
toast.info('提示訊息');
|
||||
|
||||
// 警告訊息
|
||||
toast.warning('警告訊息');
|
||||
```
|
||||
|
||||
### 10.2 常見操作的 Toast 訊息
|
||||
|
||||
```tsx
|
||||
// 新增成功
|
||||
router.post(route('resource.store'), data, {
|
||||
onSuccess: () => toast.success('新增成功'),
|
||||
onError: () => toast.error('新增失敗,請檢查輸入內容'),
|
||||
});
|
||||
|
||||
// 更新成功
|
||||
router.put(route('resource.update', id), data, {
|
||||
onSuccess: () => toast.success('更新成功'),
|
||||
onError: () => toast.error('更新失敗'),
|
||||
});
|
||||
|
||||
// 刪除成功
|
||||
router.delete(route('resource.destroy', id), {
|
||||
onSuccess: () => toast.success('已刪除'),
|
||||
onError: () => toast.error('刪除失敗,請檢查權限'),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 表單規範
|
||||
|
||||
### 11.1 表單容器
|
||||
|
||||
```tsx
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* 表單欄位 */}
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 11.2 表單欄位
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
欄位名稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.field}
|
||||
onChange={(e) => setData("field", e.target.value)}
|
||||
placeholder="請輸入..."
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||
/>
|
||||
{errors.field && <p className="mt-1 text-sm text-red-500">{errors.field}</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 11.3 下拉選單
|
||||
|
||||
使用 `SearchableSelect` 元件:
|
||||
|
||||
```tsx
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
<SearchableSelect
|
||||
value={data.category_id}
|
||||
onValueChange={(value) => setData("category_id", value)}
|
||||
options={categories.map(cat => ({ label: cat.name, value: String(cat.id) }))}
|
||||
placeholder="請選擇分類"
|
||||
searchThreshold={10} // 超過 10 個選項才顯示搜尋框
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11.4 對話框 (Dialog) 滾動與佈局
|
||||
|
||||
當對話框內容可能超出螢幕高度時(如長表單或詳細資料),**請勿使用 `ScrollArea`**,應直接在 `DialogContent` 使用原生的 `overflow-y-auto`。
|
||||
|
||||
**原因**:`ScrollArea` 在 Flex 佈局計算高度時容易失效或導致雙重滾動條。以及與原生捲動行為不一致。
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤:使用 ScrollArea 或固定高度計算
|
||||
<DialogContent className="max-w-3xl">
|
||||
<ScrollArea className="h-[500px]">
|
||||
{/* 內容 */}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
|
||||
// ✅ 正確:直接使用 overflow-y-auto 與 max-h
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>...</DialogHeader>
|
||||
<form className="p-6">
|
||||
{/* 內容會自動滾動 */}
|
||||
</form>
|
||||
<DialogFooter>...</DialogFooter>
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11.5 輸入框尺寸 (Input Sizes)
|
||||
|
||||
為確保介面整齊與統一,所有表單輸入元件標準高度應為 **`h-9`** (36px),與標準按鈕尺寸對齊。
|
||||
|
||||
- **Input**: 預設即為 `h-9` (由 `py-1` 與 `text-sm` 組合而成)
|
||||
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
||||
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
||||
|
||||
## 11.6 日期輸入框樣式 (Date Input Style)
|
||||
|
||||
日期輸入框應採用「**左側裝飾圖示 + 右側原生操作**」的配置,以保持視覺一致性並保留瀏覽器原生便利性。
|
||||
|
||||
**樣式規格**:
|
||||
1. **容器**: 使用 `relative` 定位。
|
||||
2. **圖標**: 使用 `Calendar` 圖標,放置於絕對位置 `absolute left-2.5 top-2.5`,顏色 `text-gray-400`,並設定 `pointer-events-none` 避免干擾點擊。
|
||||
3. **輸入框**: 設定 `pl-9` (左內距) 以避開圖示,並使用原生 `type="date"` 或 `type="datetime-local"`。
|
||||
|
||||
```tsx
|
||||
import { Calendar } from "lucide-react";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
className="pl-9 block w-full"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 11.7 搜尋選單樣式 (SearchableSelect Style)
|
||||
|
||||
`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。
|
||||
|
||||
```tsx
|
||||
<SearchableSelect
|
||||
className="h-9" // 確保高度一致
|
||||
// ...other props
|
||||
/>
|
||||
```
|
||||
|
||||
## 11.8 篩選列規範 (Filter Bar Norms)
|
||||
|
||||
列表頁面的篩選區域(Filter Bar)應遵循以下規範以節省空間並保持層級清晰:
|
||||
|
||||
1. **標籤文字 (Labels)**: 使用 **`text-xs`** (`12px`) 大小,顏色建議使用 `text-gray-500` 或 `text-grey-2`。這與一般表單 (`text-sm`) 不同,目的是降低篩選列的視覺權重。
|
||||
2. **輸入元件高度**: 統一使用 **`h-9`** (`36px`)。
|
||||
3. **佈局**:
|
||||
- **容器內距**: 統一使用 **`p-5`** (`20px`)。
|
||||
- **Grid 間距**: 建議使用 **`gap-4`** (`16px`) 或 `gap-6` (`24px`),但同一專案內需統一。本專案推薦 **`gap-4`**。
|
||||
- **垂直間距**: Label 與 Input 之間使用 **`space-y-1`** (`4px`)。
|
||||
- **排版**: 建議使用 Grid 系統 (`grid-cols-12`) 進行排版。
|
||||
|
||||
```tsx
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
|
||||
<Input className="h-9" placeholder="..." />
|
||||
</div>
|
||||
```
|
||||
|
||||
4. **操作按鈕區 (Action Bar)**:
|
||||
- **位置**: 位於篩選列最下方。
|
||||
- **樣式**: 統一使用 `flex items-center justify-end border-t border-grey-4 pt-5 gap-3`。
|
||||
- **說明**: `border-grey-4` 為標準通用邊框色,`pt-5` 與容器 padding (`p-5`) 呼應,維持視覺平衡。
|
||||
|
||||
5. **收合模式 (Collapsible Mode)**:
|
||||
- **目的**: 節省垂直空間,預設隱藏較佔空間與低頻使用的篩選器(如日期區間)。
|
||||
- **實作**:
|
||||
- 預設狀態:若無相關篩選值,則預設為 **收合 (Collapsed)**。
|
||||
- 切換按鈕:位於 Action Bar 左側 (`mr-auto`)。
|
||||
- 樣式:Ghost Button + `ChevronDown`/`ChevronUp` Icon + 提示圓點 (Indicator)。
|
||||
- **邏輯**: 若載入頁面時已有被隱藏的篩選值 (e.g. `date_start`),則強制 **展開 (Expanded)** 或顯示提示。
|
||||
|
||||
---
|
||||
|
||||
## 12. 檢查清單
|
||||
|
||||
在開發或審查頁面時,請確認以下項目:
|
||||
|
||||
### ✅ 按鈕
|
||||
- [ ] 使用 `button-filled-*` 或 `button-outlined-*` 類別
|
||||
- [ ] 主要操作使用 `button-filled-primary`
|
||||
- [ ] 編輯操作使用 `button-outlined-primary`
|
||||
- [ ] 刪除操作使用 `button-outlined-error`
|
||||
- [ ] 按鈕尺寸正確(sm/default/lg)
|
||||
- [ ] 包含適當的圖標
|
||||
|
||||
### ✅ 圖標
|
||||
- [ ] 全部使用 `lucide-react`
|
||||
- [ ] 尺寸正確(h-3/h-4/h-5/h-6)
|
||||
- [ ] 顏色與上下文一致
|
||||
|
||||
### ✅ 表格
|
||||
- [ ] 使用 `@/Components/ui/table` 元件
|
||||
- [ ] 有 `bg-white rounded-xl border` 容器
|
||||
- [ ] 標題列有 `bg-gray-50` 背景
|
||||
- [ ] 序號欄固定寬度並置中
|
||||
- [ ] 操作欄使用 `flex justify-center gap-2`
|
||||
- [ ] 空狀態訊息置中顯示
|
||||
|
||||
### ✅ 分頁
|
||||
- [ ] 使用 `@/Components/shared/Pagination`
|
||||
- [ ] 有每頁筆數選擇器(10/20/50/100)
|
||||
|
||||
### ✅ 權限
|
||||
- [ ] 所有操作按鈕都用 `<Can>` 包裹
|
||||
- [ ] 權限命名符合 `resource.action` 格式
|
||||
|
||||
### ✅ 通知
|
||||
- [ ] 使用 `toast` 提供操作反饋
|
||||
- [ ] 成功/錯誤訊息明確
|
||||
|
||||
### ✅ 整體
|
||||
- [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕)
|
||||
- [ ] 容器寬度使用 `max-w-7xl`
|
||||
- [ ] 使用正確的佈局(`AuthenticatedLayout`)
|
||||
|
||||
---
|
||||
|
||||
## 13. 常見錯誤與修正
|
||||
|
||||
### ❌ 錯誤:自定義按鈕樣式
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
<Button className="bg-green-500 text-white hover:bg-green-600">
|
||||
新增
|
||||
</Button>
|
||||
|
||||
// ✅ 正確
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增
|
||||
</Button>
|
||||
```
|
||||
|
||||
### ❌ 錯誤:混用圖標庫
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
import { FaEdit } from 'react-icons/fa';
|
||||
<FaEdit />
|
||||
|
||||
// ✅ 正確
|
||||
import { Pencil } from 'lucide-react';
|
||||
<Pencil className="h-4 w-4" />
|
||||
```
|
||||
|
||||
### ❌ 錯誤:操作欄未置中
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
<TableCell>
|
||||
<Button>編輯</Button>
|
||||
<Button>刪除</Button>
|
||||
</TableCell>
|
||||
|
||||
// ✅ 正確
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="button-outlined-error">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
```
|
||||
|
||||
### ❌ 錯誤:缺少權限控制
|
||||
|
||||
```tsx
|
||||
// ❌ 錯誤
|
||||
<Button onClick={handleDelete}>刪除</Button>
|
||||
|
||||
// ✅ 正確
|
||||
<Can permission="resource.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Can>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 參考範例
|
||||
|
||||
以下頁面展示了完整的 UI 統一性實踐:
|
||||
|
||||
- **使用者管理**:`resources/js/Pages/Admin/User/Index.tsx`
|
||||
- **角色管理**:`resources/js/Pages/Admin/Role/Index.tsx`
|
||||
- **產品管理**:`resources/js/Pages/Product/Index.tsx`
|
||||
- **倉庫管理**:`resources/js/Pages/Warehouse/Index.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 總結
|
||||
|
||||
遵循本規範可確保:
|
||||
|
||||
1. ✅ **視覺一致性**:所有頁面看起來像同一個系統
|
||||
2. ✅ **維護效率**:使用統一組件,修改一處即可影響全局
|
||||
3. ✅ **開發速度**:有明確的模式可循,減少決策時間
|
||||
4. ✅ **使用者體驗**:一致的互動模式降低學習成本
|
||||
5. ✅ **安全性**:統一的權限控制確保資料安全
|
||||
|
||||
當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範!
|
||||
@@ -100,8 +100,12 @@ jobs:
|
||||
npm run build &&
|
||||
|
||||
# 3. Laravel 初始化與優化
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan tenants:migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan tenants:run db:seed --option="class=PermissionSeeder" --option="force=true" &&
|
||||
php artisan permission:cache-reset &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
@@ -130,6 +134,7 @@ jobs:
|
||||
--exclude='.env' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='vendor' \
|
||||
--exclude='storage' \
|
||||
--exclude='public/build' \
|
||||
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
||||
./ root@erp.koori.tw:/var/www/star-erp/
|
||||
@@ -169,7 +174,6 @@ jobs:
|
||||
script: |
|
||||
cd /var/www/star-erp
|
||||
chown -R 1000:1000 .
|
||||
|
||||
# 檢查是否需要重建
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
@@ -192,7 +196,12 @@ jobs:
|
||||
npm install &&
|
||||
npm run build
|
||||
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan tenants:migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan tenants:run db:seed --option="class=PermissionSeeder" --option="force=true" &&
|
||||
php artisan permission:cache-reset &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -26,3 +26,5 @@ Thumbs.db
|
||||
智慧補貨系統分析報告.md
|
||||
|
||||
/docs/pptx_build
|
||||
/docs/presentation
|
||||
docs/Monthly_Report_2026_01.pptx
|
||||
|
||||
@@ -13,7 +13,7 @@ Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind
|
||||
|
||||
## 📂 系統功能詳細說明
|
||||
|
||||
### 🌳 系統功能架構樹 (含 2.0 升級規劃)
|
||||
### 🌳 預計系統功能架構樹 (含 2.0 升級規劃)
|
||||
```text
|
||||
Star ERP
|
||||
├── 🏠 儀表板 (Dashboard)
|
||||
|
||||
@@ -19,6 +19,7 @@ class TenantController extends Controller
|
||||
return [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||
@@ -47,6 +48,7 @@ class TenantController extends Controller
|
||||
$validated = $request->validate([
|
||||
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'short_name' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:100'],
|
||||
'domain' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
@@ -54,8 +56,14 @@ class TenantController extends Controller
|
||||
$tenant = Tenant::create([
|
||||
'id' => $validated['id'],
|
||||
'name' => $validated['name'],
|
||||
'short_name' => $validated['short_name'] ?? null,
|
||||
'email' => $validated['email'] ?? null,
|
||||
'is_active' => true,
|
||||
'branding' => [
|
||||
'logo_path' => 'defaults/logo.png', // 預設 Logo 路徑
|
||||
'login_background_path' => 'defaults/login_bg.jpg', // 預設登入背景
|
||||
'primary_color' => '#4F46E5', // 預設主色系 (Indigo-600)
|
||||
],
|
||||
]);
|
||||
|
||||
// 綁定網域(如果沒有輸入,使用預設網域)
|
||||
@@ -80,6 +88,7 @@ class TenantController extends Controller
|
||||
'tenant' => [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||
@@ -123,6 +132,7 @@ class TenantController extends Controller
|
||||
'tenant' => [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
],
|
||||
@@ -138,6 +148,7 @@ class TenantController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'short_name' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:100'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
@@ -37,8 +37,15 @@ class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$tenant = tenancy()->tenant;
|
||||
$appName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
|
||||
|
||||
// 分享給 Blade View (給 app.blade.php 使用)
|
||||
\Illuminate\Support\Facades\View::share('appName', $appName);
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'appName' => $appName,
|
||||
'auth' => [
|
||||
'user' => $user ? [
|
||||
'id' => $user->id,
|
||||
@@ -57,20 +64,30 @@ class HandleInertiaRequests extends Middleware
|
||||
],
|
||||
'branding' => function () {
|
||||
$tenant = tenancy()->tenant;
|
||||
if (!$tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 決定名稱顯示邏輯
|
||||
$fullName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
|
||||
$shortName = $tenant ? ($tenant->short_name ?? $fullName) : 'Start ERP';
|
||||
|
||||
$logoUrl = null;
|
||||
if (isset($tenant->branding['logo_path'])) {
|
||||
if ($tenant && isset($tenant->branding['logo_path'])) {
|
||||
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
||||
} elseif (!$tenant) {
|
||||
$logoUrl = \Storage::url('defaults/logo.png');
|
||||
}
|
||||
|
||||
return [
|
||||
$brandingData = [
|
||||
'name' => $fullName,
|
||||
'short_name' => $shortName,
|
||||
'logo_url' => $logoUrl,
|
||||
'primary_color' => $tenant->branding['primary_color'] ?? '#01ab83',
|
||||
'primary_color' => $tenant->branding['primary_color'] ?? ($tenant ? '#01ab83' : '#4F46E5'),
|
||||
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
|
||||
];
|
||||
|
||||
// 同步分享給 Blade View (給 app.blade.php 使用 Favicon)
|
||||
\Illuminate\Support\Facades\View::share('branding', $brandingData);
|
||||
|
||||
return $brandingData;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
|
||||
'App\Modules\Inventory\Models\Inventory' => '庫存',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
|
||||
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
|
||||
'App\Modules\Production\Models\Recipe' => '生產配方',
|
||||
'App\Modules\Production\Models\RecipeItem' => '配方品項',
|
||||
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
@@ -42,17 +43,27 @@ class LoginController extends Controller
|
||||
$credentials = $request->only('username', 'password');
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
// Check activation status
|
||||
if (!Auth::user()->is_active) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'username' => '此帳號已被停用,請聯繫管理員。',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
// [Hack] Demo 環境特殊規則
|
||||
$demoPort = config('tenancy.demo_tenant_port');
|
||||
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
|
||||
return redirect()->intended(route('landlord.dashboard'));
|
||||
return Inertia::location(route('landlord.dashboard'));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
return Inertia::location(route('dashboard'));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
@@ -70,6 +81,10 @@ class LoginController extends Controller
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// 強制清除 Session Cookie (對付 HTTPS/Proxy 環境下的殘留問題)
|
||||
$sessionCookieName = config('session.cookie');
|
||||
Cookie::queue(Cookie::forget($sessionCookieName));
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
|
||||
@@ -123,7 +123,7 @@ class RoleController extends Controller
|
||||
$role->syncPermissions($validated['permissions']);
|
||||
}
|
||||
|
||||
return redirect()->route('roles.index')->with('success', '角色更新成功');
|
||||
return back()->with('success', '角色更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,8 +160,13 @@ class RoleController extends Controller
|
||||
$action = $parts[1] ?? '';
|
||||
|
||||
// 特定權限遷移邏輯
|
||||
if ($permission->name === 'inventory.transfer') {
|
||||
$group = 'warehouses'; // 調撥功能移至倉庫管理下
|
||||
if ($permission->name === 'inventory.view_cost') {
|
||||
$group = 'inventory';
|
||||
}
|
||||
|
||||
// 移除不再使用的權限選項
|
||||
if (in_array($permission->name, ['inventory.count', 'inventory.transfer'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($grouped[$group])) {
|
||||
@@ -175,11 +180,18 @@ class RoleController extends Controller
|
||||
$groupDefinitions = [
|
||||
'products' => '商品資料管理',
|
||||
'warehouses' => '倉庫管理',
|
||||
'inventory' => '庫存管理',
|
||||
'inventory' => '庫存資料管理',
|
||||
'inventory_count' => '庫存盤點管理',
|
||||
'inventory_adjust' => '庫存盤調管理',
|
||||
'inventory_transfer' => '庫存調撥管理',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'recipes' => '配方管理',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'system' => '系統管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
];
|
||||
|
||||
@@ -22,9 +22,26 @@ class UserController extends Controller
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
$search = $request->input('search');
|
||||
$roleId = $request->input('role');
|
||||
|
||||
$query = User::with(['roles:id,name,display_name']);
|
||||
$roleId = $request->input('role');
|
||||
$isActive = $request->input('is_active'); // 'all', '1', '0'
|
||||
|
||||
$query = User::query();
|
||||
|
||||
// 隱藏超級管理員:若非 super-admin,則不可看到 super-admin 過往
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$query->whereDoesntHave('roles', function ($q) {
|
||||
$q->where('name', 'super-admin');
|
||||
});
|
||||
|
||||
// 預載入角色時也過濾掉 super-admin 標籤
|
||||
$query->with(['roles' => function ($q) {
|
||||
$q->select('id', 'name', 'display_name')
|
||||
->where('name', '!=', 'super-admin');
|
||||
}]);
|
||||
} else {
|
||||
$query->with(['roles:id,name,display_name']);
|
||||
}
|
||||
|
||||
// 處理搜尋
|
||||
if ($search) {
|
||||
@@ -42,6 +59,11 @@ class UserController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// 處理狀態篩選
|
||||
if ($isActive !== null && $isActive !== 'all') {
|
||||
$query->where('is_active', $isActive === '1' || $isActive === 'true');
|
||||
}
|
||||
|
||||
// 處理排序
|
||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
@@ -50,12 +72,19 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$users = $query->paginate($perPage)->withQueryString();
|
||||
$roles = Role::select('id', 'name', 'display_name')->get();
|
||||
|
||||
// 只能看到自己權限以下的角色
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Index', [
|
||||
'users' => $users,
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role']),
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role', 'is_active']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -64,7 +93,11 @@ class UserController extends Controller
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$roles = Role::pluck('display_name', 'name');
|
||||
$rolesQuery = Role::query();
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->pluck('display_name', 'name');
|
||||
|
||||
return Inertia::render('Admin/User/Create', [
|
||||
'roles' => $roles
|
||||
@@ -80,8 +113,10 @@ class UserController extends Controller
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users'],
|
||||
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.required' => '請輸入密碼',
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
@@ -92,10 +127,16 @@ class UserController extends Controller
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
|
||||
'password' => Hash::make($validated['password']),
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
if (!empty($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
$user->syncRoles($validated['roles']);
|
||||
|
||||
// 更新 'created' 紀錄以包含角色資訊
|
||||
@@ -123,7 +164,17 @@ class UserController extends Controller
|
||||
public function edit(string $id)
|
||||
{
|
||||
$user = User::with('roles')->findOrFail($id);
|
||||
$roles = Role::get(['id', 'name', 'display_name']);
|
||||
|
||||
// 安全檢查:非 super-admin 不能編輯 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Edit', [
|
||||
'user' => $user,
|
||||
@@ -139,12 +190,19 @@ class UserController extends Controller
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能更新 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
'password.confirmed' => '密碼確認不符',
|
||||
@@ -157,10 +215,6 @@ class UserController extends Controller
|
||||
'username' => $validated['username'],
|
||||
];
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$userData['password'] = Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
$user->fill($userData);
|
||||
|
||||
// 捕捉變更屬性以進行手動記錄
|
||||
@@ -179,6 +233,11 @@ class UserController extends Controller
|
||||
// 2. 處理角色
|
||||
$roleChanges = null;
|
||||
if (isset($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
|
||||
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
$user->syncRoles($validated['roles']);
|
||||
$newRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
@@ -230,6 +289,11 @@ class UserController extends Controller
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能刪除 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限刪除系統管理員');
|
||||
}
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return back()->with('error', '無法刪除超級管理員帳號');
|
||||
}
|
||||
@@ -240,6 +304,46 @@ class UserController extends Controller
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', '使用者已刪除');
|
||||
return redirect()->route('users.index')->with('success', "使用者「{$user->name}」已刪除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換使用者啟用/停用狀態
|
||||
*/
|
||||
public function toggleActive(string $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:不能停用自己
|
||||
if ($user->id === auth()->id() && $user->is_active) {
|
||||
return back()->with('error', '無法停用自己的帳號');
|
||||
}
|
||||
|
||||
// 安全檢查:非 super-admin 不能停用 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限變更系統管理員狀態');
|
||||
}
|
||||
|
||||
$oldStatus = $user->is_active;
|
||||
$user->is_active = !$oldStatus;
|
||||
$user->save();
|
||||
|
||||
// 記錄活動
|
||||
activity()
|
||||
->performedOn($user)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => ['is_active' => $user->is_active],
|
||||
'old' => ['is_active' => $oldStatus],
|
||||
'snapshot' => [
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
|
||||
$statusText = $user->is_active ? '已啟用' : '已停用';
|
||||
return back()->with('success', "使用者「{$user->name}」{$statusText}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class User extends Authenticatable
|
||||
'email',
|
||||
'username',
|
||||
'password',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,9 @@ class User extends Authenticatable
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
|
||||
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
||||
Route::patch('/users/{user}/toggle-active', [UserController::class, 'toggleActive'])->middleware('permission:users.activate')->name('users.toggle-active');
|
||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
|
||||
});
|
||||
|
||||
|
||||
210
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
210
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\AdjustService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class AdjustDocController extends Controller
|
||||
{
|
||||
protected $adjustService;
|
||||
|
||||
public function __construct(AdjustService $adjustService)
|
||||
{
|
||||
$this->adjustService = $adjustService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryAdjustDoc::query()
|
||||
->with(['createdBy', 'postedBy', 'warehouse']);
|
||||
|
||||
// 搜尋
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('doc_no', 'like', "%{$search}%")
|
||||
->orWhere('reason', 'like', "%{$search}%")
|
||||
->orWhere('remarks', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$docs = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($doc) {
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'status' => $doc->status,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'reason' => $doc->reason,
|
||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||
'posted_at' => $doc->posted_at ? $doc->posted_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'remarks' => $doc->remarks,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// 模式 1: 從盤點單建立
|
||||
if ($request->filled('count_doc_id')) {
|
||||
$countDoc = InventoryCountDoc::findOrFail($request->count_doc_id);
|
||||
|
||||
// 檢查是否已存在對應的盤調單 (避免重複建立)
|
||||
if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) {
|
||||
return redirect()->back()->with('error', '此盤點單已建立過盤調單');
|
||||
}
|
||||
|
||||
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
|
||||
return redirect()->route('inventory.adjust.show', [$doc->id])
|
||||
->with('success', '已從盤點單生成盤調單');
|
||||
}
|
||||
|
||||
// 模式 2: 一般手動調整 (保留原始邏輯但更新訊息)
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required',
|
||||
'reason' => 'required|string',
|
||||
'remarks' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$doc = $this->adjustService->createDoc(
|
||||
$validated['warehouse_id'],
|
||||
$validated['reason'],
|
||||
$validated['remarks'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return redirect()->route('inventory.adjust.show', [$doc->id])
|
||||
->with('success', '已建立盤調單');
|
||||
}
|
||||
|
||||
/**
|
||||
* API: 獲取可盤調的已完成盤點單 (支援掃描單號)
|
||||
*/
|
||||
public function getPendingCounts(Request $request)
|
||||
{
|
||||
$query = InventoryCountDoc::where('status', 'completed')
|
||||
->whereNotExists(function ($query) {
|
||||
$query->select(DB::raw(1))
|
||||
->from('inventory_adjust_docs')
|
||||
->whereColumn('inventory_adjust_docs.count_doc_id', 'inventory_count_docs.id');
|
||||
});
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where('doc_no', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$counts = $query->limit(10)->get()->map(function($c) {
|
||||
return [
|
||||
'id' => (string)$c->id,
|
||||
'doc_no' => $c->doc_no,
|
||||
'warehouse_name' => $c->warehouse->name,
|
||||
'completed_at' => $c->completed_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($counts);
|
||||
}
|
||||
|
||||
public function show(InventoryAdjustDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_id' => (string) $doc->warehouse_id,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'status' => $doc->status,
|
||||
'reason' => $doc->reason,
|
||||
'remarks' => $doc->remarks,
|
||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
|
||||
'count_doc_no' => $doc->countDoc?->doc_no,
|
||||
'items' => $doc->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_id' => (string) $item->product_id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'qty_before' => (float) $item->qty_before,
|
||||
'adjust_qty' => (float) $item->adjust_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Show', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryAdjustDoc $doc)
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
// 提交 (items 更新 或 過帳)
|
||||
if ($request->input('action') === 'post') {
|
||||
$this->adjustService->post($doc, auth()->id());
|
||||
return redirect()->route('inventory.adjust.index')
|
||||
->with('success', '盤調單已過帳生效');
|
||||
}
|
||||
|
||||
// 僅儲存資料
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.adjust_qty' => 'required|numeric', // 可以是負數
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($request->has('items')) {
|
||||
$this->adjustService->updateItems($doc, $validated['items']);
|
||||
}
|
||||
|
||||
// 更新表頭
|
||||
$doc->update($request->only(['reason', 'remarks']));
|
||||
|
||||
return redirect()->back()->with('success', '儲存成功');
|
||||
}
|
||||
|
||||
public function destroy(InventoryAdjustDoc $doc)
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
return redirect()->route('inventory.adjust.index')
|
||||
->with('success', '盤調單已刪除');
|
||||
}
|
||||
}
|
||||
215
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
215
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\CountService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CountDocController extends Controller
|
||||
{
|
||||
protected $countService;
|
||||
|
||||
public function __construct(CountService $countService)
|
||||
{
|
||||
$this->countService = $countService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryCountDoc::query()
|
||||
->with(['createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('doc_no', 'like', "%{$search}%")
|
||||
->orWhere('remarks', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
}
|
||||
|
||||
$countQuery = function ($query) {
|
||||
$query->whereNotNull('counted_qty');
|
||||
};
|
||||
|
||||
$docs = $query->withCount(['items', 'items as counted_items_count' => $countQuery])
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($doc) {
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'status' => $doc->status,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-',
|
||||
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'remarks' => $doc->remarks,
|
||||
'total_items' => $doc->items_count,
|
||||
'counted_items' => $doc->counted_items_count,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Count/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'remarks' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$doc = $this->countService->createDoc(
|
||||
$validated['warehouse_id'],
|
||||
$validated['remarks'] ?? null,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 自動執行快照
|
||||
$this->countService->snapshot($doc);
|
||||
|
||||
return redirect()->route('inventory.count.show', [$doc->id])
|
||||
->with('success', '已建立盤點單並完成庫存快照');
|
||||
}
|
||||
|
||||
public function show(InventoryCountDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_id' => (string) $doc->warehouse_id,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'status' => $doc->status,
|
||||
'remarks' => $doc->remarks,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'items' => $doc->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'system_qty' => (float) $item->system_qty,
|
||||
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
|
||||
'diff_qty' => (float) $item->diff_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Count/Show', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(InventoryCountDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d') : date('Y-m-d'), // Use date only
|
||||
'created_at' => $doc->created_at->format('Y-m-d'),
|
||||
'print_date' => date('Y-m-d'),
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'items' => $doc->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'specification' => $item->product->specification,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'quantity' => (float) ($item->counted_qty ?? $item->system_qty), // Default to system qty if counted is null, or just counted? User wants "Count Sheet" -> maybe blank if not counted?
|
||||
// Actually, if it's "Completed", we show counted. If it's "Pending", we usually show blank or system.
|
||||
// The 'Show' page logic suggests we show counted_qty.
|
||||
'counted_qty' => $item->counted_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Count/Print', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '此盤點單已完成,無法修改');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.id' => 'required|exists:inventory_count_items,id',
|
||||
'items.*.counted_qty' => 'nullable|numeric|min:0',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if (isset($validated['items'])) {
|
||||
$this->countService->updateCount($doc, $validated['items']);
|
||||
}
|
||||
|
||||
// 如果是按了 "完成盤點"
|
||||
if ($request->input('action') === 'complete') {
|
||||
$this->countService->complete($doc, auth()->id());
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點單已完成');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '暫存成功');
|
||||
}
|
||||
|
||||
public function reopen(InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status !== 'completed') {
|
||||
return redirect()->back()->with('error', '只有已核准的盤點單可以取消核准');
|
||||
}
|
||||
|
||||
// TODO: Move logic to Service if complex
|
||||
$doc->update([
|
||||
'status' => 'counting', // Revert to counting (draft)
|
||||
'completed_at' => null,
|
||||
'completed_by' => null,
|
||||
]);
|
||||
|
||||
return redirect()->route('inventory.count.show', [$doc->id])
|
||||
->with('success', '已取消核准,單據回復為盤點中狀態');
|
||||
}
|
||||
|
||||
public function destroy(InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
|
||||
}
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點單已刪除');
|
||||
}
|
||||
}
|
||||
248
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
248
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Services\GoodsReceiptService;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
protected $goodsReceiptService;
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(
|
||||
GoodsReceiptService $goodsReceiptService,
|
||||
InventoryService $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
) {
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = GoodsReceipt::query()
|
||||
->select(['id', 'code', 'type', 'warehouse_id', 'vendor_id', 'received_date', 'status', 'created_at'])
|
||||
->with(['warehouse'])
|
||||
->withSum('items', 'total_amount');
|
||||
|
||||
// 關鍵字搜尋(單號)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where('code', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->filled('status') && $request->input('status') !== 'all') {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
// 倉庫篩選
|
||||
if ($request->filled('warehouse_id') && $request->input('warehouse_id') !== 'all') {
|
||||
$query->where('warehouse_id', $request->input('warehouse_id'));
|
||||
}
|
||||
|
||||
// 日期範圍篩選
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('received_date', '>=', $request->input('date_start'));
|
||||
}
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('received_date', '<=', $request->input('date_end'));
|
||||
}
|
||||
|
||||
// 每頁筆數
|
||||
$perPage = $request->input('per_page', 10);
|
||||
|
||||
$receipts = $query->orderBy('created_at', 'desc')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
// Manual Hydration for Vendors (Cross-Module)
|
||||
$vendorIds = collect($receipts->items())->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendors = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
|
||||
|
||||
$receipts->getCollection()->transform(function ($receipt) use ($vendors) {
|
||||
$receipt->vendor = $vendors->get($receipt->vendor_id);
|
||||
return $receipt;
|
||||
});
|
||||
|
||||
// 取得倉庫列表用於篩選
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Index', [
|
||||
'receipts' => $receipts,
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'per_page']),
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$receipt = GoodsReceipt::with([
|
||||
'warehouse',
|
||||
'items.product.category',
|
||||
'items.product.baseUnit'
|
||||
])->findOrFail($id);
|
||||
|
||||
// Manual Hydration for Vendor (Cross-Module)
|
||||
if ($receipt->vendor_id) {
|
||||
$receipt->vendor = $this->procurementService->getVendorsByIds([$receipt->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 手動計算統計資訊 (如果 Model 沒有定義對應的 Attribute)
|
||||
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Show', [
|
||||
'receipt' => $receipt
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// 取得待進貨的採購單列表(用於標準採購類型選擇)
|
||||
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
|
||||
|
||||
// 提取所有產品 ID 以便跨模組水和資料
|
||||
$productIds = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// 處理採購單資料,計算剩餘可收貨數量
|
||||
$formattedPOs = $pendingPOs->map(function ($po) use ($products) {
|
||||
return [
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'vendor_id' => $po->vendor_id,
|
||||
'vendor_name' => $po->vendor?->name ?? '',
|
||||
'warehouse_id' => $po->warehouse_id,
|
||||
'order_date' => $po->order_date,
|
||||
'items' => $po->items->map(function ($item) use ($products) {
|
||||
$product = $products->get($item->product_id);
|
||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $product?->baseUnit?->name ?? '個',
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unit_price' => $item->unit_price,
|
||||
];
|
||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||
];
|
||||
})->filter(fn($po) => $po['items']->count() > 0)->values();
|
||||
|
||||
// 取得所有廠商列表(用於雜項入庫/其他類型選擇)
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Create', [
|
||||
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||
'pendingPurchaseOrders' => $formattedPOs,
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'type' => 'required|in:standard,miscellaneous,other',
|
||||
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
|
||||
// Vendor ID is required if standard, but optional/nullable for misc/other?
|
||||
// Stick to existing logic: if standard, we infer vendor from PO usually, or frontend sends it.
|
||||
// For now let's make vendor_id optional for misc/other or user must select one?
|
||||
// "雜項入庫" might not have a vendor. Let's make it nullable.
|
||||
'vendor_id' => 'nullable|integer',
|
||||
'received_date' => 'required|date',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|integer|exists:products,id',
|
||||
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
|
||||
'items.*.quantity_received' => 'required|numeric|min:0',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.expiry_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$this->goodsReceiptService->store($validated);
|
||||
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨單已建立');
|
||||
}
|
||||
|
||||
// API to search POs
|
||||
public function searchPOs(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$pos = $this->procurementService->searchPendingPurchaseOrders($search);
|
||||
|
||||
return response()->json($pos);
|
||||
}
|
||||
|
||||
// API to search Products for Manual Entry
|
||||
public function searchProducts(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$products = $this->inventoryService->getProductsByName($search);
|
||||
|
||||
// Format for frontend
|
||||
$mapped = $products->map(function($product) {
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
|
||||
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($mapped);
|
||||
}
|
||||
|
||||
// API to search Vendors
|
||||
public function searchVendors(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$vendors = $this->procurementService->searchVendors($search);
|
||||
|
||||
return response()->json($vendors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除進貨單
|
||||
*/
|
||||
public function destroy(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
// 只有有權限的人可以刪除
|
||||
if (!auth()->user()->can('goods_receipts.delete')) {
|
||||
return redirect()->back()->with('error', '您沒有權限刪除進貨單');
|
||||
}
|
||||
|
||||
// 簡單刪除邏輯:刪除進貨單(品項由資料庫級聯刪除或手動處理)
|
||||
// 注意:實務上可能需要處理已入庫的庫存回滾,但在這個簡易 ERP 中通常是行政刪除
|
||||
$goodsReceipt->delete();
|
||||
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
@@ -482,7 +483,60 @@ class InventoryController extends Controller
|
||||
$productId = $request->query('productId');
|
||||
|
||||
if ($productId) {
|
||||
// ... (略) ...
|
||||
$product = Product::findOrFail($productId);
|
||||
// 取得該倉庫中該商品的所有批號 ID
|
||||
$inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
$transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
|
||||
->with('inventory') // 需要批號資訊
|
||||
->orderBy('actual_time', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
// 手動 Hydrate 使用者資料
|
||||
$userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
// 計算商品在該倉庫的總量(不分批號)
|
||||
$currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
$transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
|
||||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||
$balanceAfter = $currentRunningTotal;
|
||||
|
||||
// 為下一筆(較舊的)紀錄更新 Running Total
|
||||
$currentRunningTotal -= (float) $tx->quantity;
|
||||
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
|
||||
];
|
||||
});
|
||||
|
||||
// 重新計算目前的總量(用於 Header 顯示,確保一致性)
|
||||
$totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
return Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => null, // 跨批號查詢沒有單一 ID
|
||||
'productName' => $product->name,
|
||||
'productCode' => $product->code,
|
||||
'batchNumber' => '所有批號',
|
||||
'quantity' => (float) $totalQuantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
|
||||
if ($inventoryId) {
|
||||
|
||||
@@ -10,6 +10,9 @@ use App\Modules\Inventory\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Modules\Inventory\Exports\ProductTemplateExport;
|
||||
use App\Modules\Inventory\Imports\ProductImport;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
@@ -25,6 +28,7 @@ class ProductController extends Controller
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('brand', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
@@ -66,6 +70,7 @@ class ProductController extends Controller
|
||||
return (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'category' => $product->category ? (object) [
|
||||
@@ -90,6 +95,7 @@ class ProductController extends Controller
|
||||
'name' => $product->purchaseUnit->name,
|
||||
] : null,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'location' => $product->location,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -109,7 +115,8 @@ class ProductController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:2|unique:products,code',
|
||||
'code' => 'required|string|min:2|max:8|unique:products,code',
|
||||
'barcode' => 'required|string|unique:products,barcode',
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
@@ -119,10 +126,14 @@ class ProductController extends Controller
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
], [
|
||||
'code.required' => '商品代號為必填',
|
||||
'code.max' => '商品代號最多 2 碼',
|
||||
'code.max' => '商品代號最多 8 碼',
|
||||
'code.min' => '商品代號最少 2 碼',
|
||||
'code.unique' => '商品代號已存在',
|
||||
'barcode.required' => '條碼編號為必填',
|
||||
'barcode.unique' => '條碼編號已存在',
|
||||
'name.required' => '商品名稱為必填',
|
||||
'category_id.required' => '請選擇分類',
|
||||
'category_id.exists' => '所選分類不存在',
|
||||
@@ -144,7 +155,8 @@ class ProductController extends Controller
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
|
||||
'code' => 'required|string|min:2|max:8|unique:products,code,' . $product->id,
|
||||
'barcode' => 'required|string|unique:products,barcode,' . $product->id,
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
@@ -153,10 +165,14 @@ class ProductController extends Controller
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
], [
|
||||
'code.required' => '商品代號為必填',
|
||||
'code.max' => '商品代號最多 2 碼',
|
||||
'code.max' => '商品代號最多 8 碼',
|
||||
'code.min' => '商品代號最少 2 碼',
|
||||
'code.unique' => '商品代號已存在',
|
||||
'barcode.required' => '條碼編號為必填',
|
||||
'barcode.unique' => '條碼編號已存在',
|
||||
'name.required' => '商品名稱為必填',
|
||||
'category_id.required' => '請選擇分類',
|
||||
'category_id.exists' => '所選分類不存在',
|
||||
@@ -181,4 +197,36 @@ class ProductController extends Controller
|
||||
|
||||
return redirect()->back()->with('success', '商品已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載匯入範本
|
||||
*/
|
||||
public function template()
|
||||
{
|
||||
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯入商品
|
||||
*/
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls',
|
||||
]);
|
||||
|
||||
try {
|
||||
Excel::import(new ProductImport, $request->file('file'));
|
||||
return redirect()->back()->with('success', '商品匯入成功');
|
||||
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
|
||||
$failures = $e->failures();
|
||||
$messages = [];
|
||||
foreach ($failures as $failure) {
|
||||
$messages[] = '第 ' . $failure->row() . ' 行: ' . implode(', ', $failure->errors());
|
||||
}
|
||||
return redirect()->back()->withErrors(['file' => implode("\n", $messages)]);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,135 +3,212 @@
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Services\TransferService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TransferOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 儲存撥補單(建立調撥單並執行庫存轉移)
|
||||
*/
|
||||
protected $transferService;
|
||||
|
||||
public function __construct(TransferService $transferService)
|
||||
{
|
||||
$this->transferService = $transferService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryTransferOrder::query()
|
||||
->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
|
||||
// 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('from_warehouse_id', $request->warehouse_id)
|
||||
->orWhere('to_warehouse_id', $request->warehouse_id);
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$orders = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($order) {
|
||||
return [
|
||||
'id' => (string) $order->id,
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'status' => $order->status,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $order->createdBy?->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Index', [
|
||||
'orders' => $orders,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// 兼容前端不同的參數命名 (from/source, to/target)
|
||||
$fromId = $request->input('from_warehouse_id') ?? $request->input('sourceWarehouseId');
|
||||
$toId = $request->input('to_warehouse_id') ?? $request->input('targetWarehouseId');
|
||||
|
||||
$validated = $request->validate([
|
||||
'sourceWarehouseId' => 'required|exists:warehouses,id',
|
||||
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
|
||||
'productId' => 'required|exists:products,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'transferDate' => 'required|date',
|
||||
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
|
||||
'from_warehouse_id' => 'required_without:sourceWarehouseId|exists:warehouses,id',
|
||||
'to_warehouse_id' => 'required_without:targetWarehouseId|exists:warehouses,id|different:from_warehouse_id',
|
||||
'remarks' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
|
||||
'instant_post' => 'boolean',
|
||||
// 支援單筆商品直接建立 (撥補單模式)
|
||||
'product_id' => 'nullable|exists:products,id',
|
||||
'quantity' => 'nullable|numeric|min:0.01',
|
||||
'batch_number' => 'nullable|string',
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated) {
|
||||
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||
->where('product_id', $validated['productId'])
|
||||
->where('batch_number', $validated['batchNumber'])
|
||||
->first();
|
||||
$remarks = $validated['remarks'] ?? $validated['notes'] ?? null;
|
||||
$order = $this->transferService->createOrder(
|
||||
$fromId,
|
||||
$toId,
|
||||
$remarks,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => ['來源倉庫指定批號庫存不足'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $validated['targetWarehouseId'],
|
||||
'product_id' => $validated['productId'],
|
||||
'batch_number' => $validated['batchNumber'],
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'total_value' => 0,
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
);
|
||||
|
||||
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
|
||||
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
|
||||
|
||||
// 3. 執行庫存轉移 (扣除來源)
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $validated['quantity'];
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源異動
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '撥補出庫',
|
||||
'quantity' => -$validated['quantity'],
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 4. 執行庫存轉移 (增加目標)
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $validated['quantity'];
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
|
||||
// 確保目標庫存也有成本 (如果是繼承來的)
|
||||
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
|
||||
$targetInventory->save();
|
||||
|
||||
// 記錄目標異動
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '撥補入庫',
|
||||
// 如果請求包含單筆商品資訊
|
||||
if ($request->has('product_id')) {
|
||||
$this->transferService->updateItems($order, [[
|
||||
'product_id' => $validated['product_id'],
|
||||
'quantity' => $validated['quantity'],
|
||||
'unit_cost' => $targetInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
'batch_number' => $validated['batch_number'] ?? null,
|
||||
]]);
|
||||
}
|
||||
|
||||
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
|
||||
// 如果是撥補單,執行直接過帳
|
||||
if ($request->input('instant_post') === true) {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
return redirect()->back()->with('success', '撥補成功,庫存已更新');
|
||||
} catch (\Exception $e) {
|
||||
// 如果過帳失敗,雖然單據已建立,但應回報錯誤
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
|
||||
});
|
||||
return redirect()->route('inventory.transfer.show', [$order->id])
|
||||
->with('success', '已建立調撥單');
|
||||
}
|
||||
|
||||
public function show(InventoryTransferOrder $order)
|
||||
{
|
||||
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
|
||||
$orderData = [
|
||||
'id' => (string) $order->id,
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_id' => (string) $order->from_warehouse_id,
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'to_warehouse_id' => (string) $order->to_warehouse_id,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'status' => $order->status,
|
||||
'remarks' => $order->remarks,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $order->createdBy?->name,
|
||||
'items' => $order->items->map(function ($item) use ($order) {
|
||||
// 獲取來源倉庫的當前庫存
|
||||
$stock = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_id' => (string) $item->product_id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'quantity' => (float) $item->quantity,
|
||||
'max_quantity' => $stock ? (float) $stock->quantity : 0.0,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Show', [
|
||||
'order' => $orderData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryTransferOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
if ($request->input('action') === 'post') {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已過帳完成');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($request->has('items')) {
|
||||
$this->transferService->updateItems($order, $validated['items']);
|
||||
}
|
||||
|
||||
$order->update($request->only(['remarks']));
|
||||
|
||||
return redirect()->back()->with('success', '儲存成功');
|
||||
}
|
||||
|
||||
public function destroy(InventoryTransferOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取特定倉庫的庫存列表 (API)
|
||||
* 獲取特定倉庫的庫存列表 (API) - 保留給前端選擇商品用
|
||||
*/
|
||||
public function getWarehouseInventories(Warehouse $warehouse)
|
||||
{
|
||||
$inventories = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'product.category'])
|
||||
->where('quantity', '>', 0) // 只回傳有庫存的
|
||||
->where('quantity', '>', 0)
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'product_id' => (string) $inv->product_id,
|
||||
'product_name' => $inv->product->name,
|
||||
'product_code' => $inv->product->code, // Added code
|
||||
'batch_number' => $inv->batch_number,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'unit_cost' => (float) $inv->unit_cost, // 新增
|
||||
'total_value' => (float) $inv->total_value, // 新增
|
||||
'unit_cost' => (float) $inv->unit_cost,
|
||||
'unit_name' => $inv->product->baseUnit?->name ?? '個',
|
||||
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
];
|
||||
|
||||
@@ -24,34 +24,43 @@ class WarehouseController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
}
|
||||
|
||||
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
|
||||
->withSum(['inventories as available_stock' => function ($query) {
|
||||
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期)
|
||||
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期) 且 倉庫類型不為瑕疵倉
|
||||
$query->where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
});
|
||||
}], 'quantity')
|
||||
->addSelect(['low_stock_count' => function ($query) {
|
||||
$query->selectRaw('count(*)')
|
||||
->from('warehouse_product_safety_stocks as ss')
|
||||
->whereColumn('ss.warehouse_id', 'warehouses.id')
|
||||
->whereRaw('(SELECT COALESCE(SUM(quantity), 0) FROM inventories WHERE warehouse_id = ss.warehouse_id AND product_id = ss.product_id) < ss.safety_stock');
|
||||
}])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
// 修正各倉庫列表中的可用庫存計算:若倉庫不可銷售,則可用庫存為 0
|
||||
$warehouses->getCollection()->transform(function ($w) {
|
||||
if (!$w->is_sellable) {
|
||||
$w->available_stock = 0;
|
||||
}
|
||||
return $w;
|
||||
});
|
||||
// 移除原本對 is_sellable 的手動修正邏輯,現在由 type 自動過濾
|
||||
|
||||
|
||||
// 計算全域總計 (不分頁)
|
||||
$totals = [
|
||||
'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('is_sellable', true);
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
@@ -63,7 +72,7 @@ class WarehouseController extends Controller
|
||||
return Inertia::render('Warehouse/Index', [
|
||||
'warehouses' => $warehouses,
|
||||
'totals' => $totals,
|
||||
'filters' => $request->only(['search']),
|
||||
'filters' => $request->only(['search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -73,7 +82,6 @@ class WarehouseController extends Controller
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
@@ -98,7 +106,6 @@ class WarehouseController extends Controller
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
|
||||
36
app/Modules/Inventory/Exports/ProductTemplateExport.php
Normal file
36
app/Modules/Inventory/Exports/ProductTemplateExport.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class ProductTemplateExport implements WithHeadings, WithColumnFormatting
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'商品代號',
|
||||
'條碼',
|
||||
'商品名稱',
|
||||
'類別名稱',
|
||||
'品牌',
|
||||
'規格',
|
||||
'基本單位',
|
||||
'大單位',
|
||||
'換算率',
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return [
|
||||
'A' => NumberFormat::FORMAT_TEXT, // 商品代號
|
||||
'B' => NumberFormat::FORMAT_TEXT, // 條碼
|
||||
];
|
||||
}
|
||||
}
|
||||
105
app/Modules/Inventory/Imports/ProductImport.php
Normal file
105
app/Modules/Inventory/Imports/ProductImport.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Imports;
|
||||
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
|
||||
|
||||
class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapping
|
||||
{
|
||||
private $categories;
|
||||
private $units;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 禁用標題格式化,保留中文標題
|
||||
HeadingRowFormatter::default('none');
|
||||
|
||||
// 快取所有類別與單位,避免 N+1 查詢
|
||||
$this->categories = Category::pluck('id', 'name');
|
||||
$this->units = Unit::pluck('id', 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $row
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
|
||||
if (isset($row['商品代號'])) {
|
||||
$row['商品代號'] = (string) $row['商品代號'];
|
||||
}
|
||||
if (isset($row['條碼'])) {
|
||||
$row['條碼'] = (string) $row['條碼'];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
*/
|
||||
public function model(array $row)
|
||||
{
|
||||
// 查找關聯 ID
|
||||
$categoryId = $this->categories[$row['類別名稱']] ?? null;
|
||||
$baseUnitId = $this->units[$row['基本單位']] ?? null;
|
||||
$largeUnitId = isset($row['大單位']) ? ($this->units[$row['大單位']] ?? null) : null;
|
||||
|
||||
|
||||
// 若必要關聯找不到,理論上 Validation 會攔截,但此處做防禦性編程
|
||||
if (!$categoryId || !$baseUnitId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Product([
|
||||
'code' => $row['商品代號'],
|
||||
'barcode' => $row['條碼'],
|
||||
'name' => $row['商品名稱'],
|
||||
'category_id' => $categoryId,
|
||||
'brand' => $row['品牌'] ?? null,
|
||||
'specification' => $row['規格'] ?? null,
|
||||
'base_unit_id' => $baseUnitId,
|
||||
'large_unit_id' => $largeUnitId,
|
||||
'conversion_rate' => $row['換算率'] ?? null,
|
||||
'purchase_unit_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'商品代號' => ['required', 'string', 'min:2', 'max:8', 'unique:products,code'],
|
||||
'條碼' => ['required', 'string', 'unique:products,barcode'],
|
||||
'商品名稱' => ['required', 'string'],
|
||||
'類別名稱' => ['required', function($attribute, $value, $fail) {
|
||||
if (!isset($this->categories[$value])) {
|
||||
$fail("找不到類別: " . $value);
|
||||
}
|
||||
}],
|
||||
'基本單位' => ['required', function($attribute, $value, $fail) {
|
||||
if (!isset($this->units[$value])) {
|
||||
$fail("找不到單位: " . $value);
|
||||
}
|
||||
}],
|
||||
'大單位' => ['nullable', function($attribute, $value, $fail) {
|
||||
if ($value && !isset($this->units[$value])) {
|
||||
$fail("找不到單位: " . $value);
|
||||
}
|
||||
}],
|
||||
|
||||
'換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'],
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Modules/Inventory/Models/GoodsReceipt.php
Normal file
51
app/Modules/Inventory/Models/GoodsReceipt.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class GoodsReceipt extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'type',
|
||||
'warehouse_id',
|
||||
'purchase_order_id',
|
||||
'vendor_id',
|
||||
'received_date',
|
||||
'status',
|
||||
'remarks',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'received_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(GoodsReceiptItem::class);
|
||||
}
|
||||
|
||||
// Strict Mode: relationships to Warehouse is allowed (same module).
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
// Strict Mode: cross-module relationship to Vendor/User/PurchaseOrder is restricted.
|
||||
// They are accessed via IDs or Services.
|
||||
}
|
||||
39
app/Modules/Inventory/Models/GoodsReceiptItem.php
Normal file
39
app/Modules/Inventory/Models/GoodsReceiptItem.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class GoodsReceiptItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'goods_receipt_id',
|
||||
'product_id',
|
||||
'purchase_order_item_id',
|
||||
'quantity_received',
|
||||
'unit_price',
|
||||
'total_amount',
|
||||
'batch_number',
|
||||
'expiry_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_received' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2', // 暫定價格
|
||||
'total_amount' => 'decimal:2',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function goodsReceipt()
|
||||
{
|
||||
return $this->belongsTo(GoodsReceipt::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
81
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
81
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryAdjustDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'count_doc_id',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'reason',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'ADJ' . $today;
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function countDoc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryAdjustItem::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
}
|
||||
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryAdjustItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'adjust_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'qty_before',
|
||||
'adjust_qty', // 增減數量
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty_before' => 'decimal:2',
|
||||
'adjust_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryAdjustDoc::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
78
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
78
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryCountDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'snapshot_date',
|
||||
'completed_at',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'completed_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_date' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'CNT' . $today;
|
||||
|
||||
// 查詢當天編號最大的單據
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
// 取得最後兩位序號並遞增
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryCountItem::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function completedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'completed_by');
|
||||
}
|
||||
}
|
||||
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryCountItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'count_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'system_qty',
|
||||
'counted_qty',
|
||||
'diff_qty',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'system_qty' => 'decimal:2',
|
||||
'counted_qty' => 'decimal:2',
|
||||
'diff_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
34
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
34
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryTransferItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'transfer_order_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
80
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
80
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryTransferOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'from_warehouse_id',
|
||||
'to_warehouse_id',
|
||||
'status',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'TRF' . $today;
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function fromWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'from_warehouse_id');
|
||||
}
|
||||
|
||||
public function toWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'to_warehouse_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryTransferItem::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class Product extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'barcode',
|
||||
'name',
|
||||
'category_id',
|
||||
'brand',
|
||||
@@ -25,6 +26,7 @@ class Product extends Model
|
||||
'large_unit_id',
|
||||
'conversion_rate',
|
||||
'purchase_unit_id',
|
||||
'location',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@@ -18,13 +18,11 @@ class Warehouse extends Model
|
||||
'type',
|
||||
'address',
|
||||
'description',
|
||||
'is_sellable',
|
||||
'license_plate',
|
||||
'driver_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_sellable' => 'boolean',
|
||||
'type' => \App\Enums\WarehouseType::class,
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Modules\Inventory\Controllers\WarehouseController;
|
||||
use App\Modules\Inventory\Controllers\InventoryController;
|
||||
use App\Modules\Inventory\Controllers\SafetyStockController;
|
||||
use App\Modules\Inventory\Controllers\TransferOrderController;
|
||||
use App\Modules\Inventory\Controllers\CountDocController;
|
||||
use App\Modules\Inventory\Controllers\AdjustDocController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
@@ -20,14 +22,16 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
|
||||
// 單位管理 - 需要商品權限
|
||||
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||
Route::post('/units', [UnitController::class, 'store'])->name('units.store');
|
||||
Route::put('/units/{unit}', [UnitController::class, 'update'])->name('units.update');
|
||||
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->name('units.destroy');
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::post('/units', [UnitController::class, 'store'])->middleware('permission:products.create')->name('units.store');
|
||||
Route::put('/units/{unit}', [UnitController::class, 'update'])->middleware('permission:products.edit')->name('units.update');
|
||||
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->middleware('permission:products.delete')->name('units.destroy');
|
||||
});
|
||||
|
||||
// 商品管理
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/products/template', [ProductController::class, 'template'])->name('products.template');
|
||||
Route::post('/products/import', [ProductController::class, 'import'])->middleware('permission:products.create')->name('products.import');
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
||||
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
|
||||
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');
|
||||
@@ -54,7 +58,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy');
|
||||
});
|
||||
|
||||
// API: 取得商品在特定倉庫的所有批號
|
||||
// API: 取得商品在特定倉庫的所有批號
|
||||
Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches'])
|
||||
->name('api.warehouses.inventory.batches');
|
||||
});
|
||||
@@ -70,11 +74,47 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 撥補單 (在庫存調撥時使用)
|
||||
Route::middleware('permission:inventory.transfer')->group(function () {
|
||||
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
||||
// 庫存盤點 (Stock Counting) - Global
|
||||
Route::middleware('permission:inventory_count.view')->group(function () {
|
||||
Route::get('/inventory/count-docs', [CountDocController::class, 'index'])->name('inventory.count.index');
|
||||
Route::get('/inventory/count-docs/{doc}', [CountDocController::class, 'show'])->name('inventory.count.show');
|
||||
Route::get('/inventory/count-docs/{doc}/print', [CountDocController::class, 'print'])->name('inventory.count.print');
|
||||
});
|
||||
Route::post('/inventory/count-docs', [CountDocController::class, 'store'])->middleware('permission:inventory_count.create')->name('inventory.count.store');
|
||||
Route::put('/inventory/count-docs/{doc}', [CountDocController::class, 'update'])->middleware('permission:inventory_count.edit')->name('inventory.count.update');
|
||||
Route::delete('/inventory/count-docs/{doc}', [CountDocController::class, 'destroy'])->middleware('permission:inventory_count.delete')->name('inventory.count.destroy');
|
||||
Route::put('/inventory/count-docs/{doc}/reopen', [CountDocController::class, 'reopen'])->middleware('permission:inventory_count.edit')->name('inventory.count.reopen');
|
||||
|
||||
// 庫存盤調 (Stock Adjustment) - Global
|
||||
Route::middleware('permission:inventory_adjust.view')->group(function () {
|
||||
Route::get('/inventory/adjust-docs', [AdjustDocController::class, 'index'])->name('inventory.adjust.index');
|
||||
Route::get('/inventory/adjust-docs/get-pending-counts', [AdjustDocController::class, 'getPendingCounts'])->name('inventory.adjust.pending-counts');
|
||||
Route::get('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'show'])->name('inventory.adjust.show');
|
||||
});
|
||||
Route::post('/inventory/adjust-docs', [AdjustDocController::class, 'store'])->middleware('permission:inventory_adjust.create')->name('inventory.adjust.store');
|
||||
Route::put('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'update'])->middleware('permission:inventory_adjust.edit')->name('inventory.adjust.update');
|
||||
Route::delete('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'destroy'])->middleware('permission:inventory_adjust.delete')->name('inventory.adjust.destroy');
|
||||
|
||||
// 撥補單/調撥單 (Transfer Order) - Global
|
||||
Route::middleware('permission:inventory_transfer.view')->group(function () {
|
||||
Route::get('/inventory/transfer-orders', [TransferOrderController::class, 'index'])->name('inventory.transfer.index');
|
||||
Route::get('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'show'])->name('inventory.transfer.show');
|
||||
});
|
||||
Route::post('/inventory/transfer-orders', [TransferOrderController::class, 'store'])->middleware('permission:inventory_transfer.create')->name('inventory.transfer.store');
|
||||
Route::put('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'update'])->middleware('permission:inventory_transfer.edit')->name('inventory.transfer.update');
|
||||
Route::delete('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'destroy'])->middleware('permission:inventory_transfer.delete')->name('inventory.transfer.destroy');
|
||||
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
|
||||
->middleware('permission:inventory.view')
|
||||
->name('api.warehouses.inventories');
|
||||
|
||||
// 進貨單 (Goods Receipts)
|
||||
Route::middleware('permission:goods_receipts.view')->group(function () {
|
||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
||||
Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create');
|
||||
Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show');
|
||||
Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
|
||||
Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos');
|
||||
Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products');
|
||||
Route::get('/api/goods-receipts/search-vendors', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchVendors'])->name('goods-receipts.search-vendors');
|
||||
});
|
||||
});
|
||||
|
||||
160
app/Modules/Inventory/Services/AdjustService.php
Normal file
160
app/Modules/Inventory/Services/AdjustService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdjustService
|
||||
{
|
||||
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
|
||||
{
|
||||
return InventoryAdjustDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'count_doc_id' => $countDocId,
|
||||
'status' => 'draft',
|
||||
'reason' => $reason,
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從盤點單建立盤調單
|
||||
*/
|
||||
public function createFromCountDoc(InventoryCountDoc $countDoc, int $userId): InventoryAdjustDoc
|
||||
{
|
||||
return DB::transaction(function () use ($countDoc, $userId) {
|
||||
// 1. 建立盤調單頭
|
||||
$adjDoc = $this->createDoc(
|
||||
$countDoc->warehouse_id,
|
||||
"盤點調整: " . $countDoc->doc_no,
|
||||
"由盤點單 {$countDoc->doc_no} 自動生成",
|
||||
$userId,
|
||||
$countDoc->id
|
||||
);
|
||||
|
||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||
foreach ($countDoc->items as $item) {
|
||||
if (abs($item->diff_qty) < 0.0001) continue;
|
||||
|
||||
$adjDoc->items()->create([
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'qty_before' => $item->system_qty,
|
||||
'adjust_qty' => $item->diff_qty,
|
||||
'notes' => "盤點差異: " . $item->diff_qty,
|
||||
]);
|
||||
}
|
||||
|
||||
return $adjDoc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新盤調單內容 (Items)
|
||||
* 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠
|
||||
*/
|
||||
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
$doc->items()->delete();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->where('product_id', $data['product_id'])
|
||||
->where('batch_number', $data['batch_number'] ?? null)
|
||||
->first();
|
||||
|
||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||
|
||||
$doc->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'qty_before' => $qtyBefore,
|
||||
'adjust_qty' => $data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 生效庫存異動
|
||||
*/
|
||||
public function post(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
foreach ($doc->items as $item) {
|
||||
if ($item->adjust_qty == 0) continue;
|
||||
|
||||
// 找尋或建立 Inventory
|
||||
// 若是減少庫存,必須確保 Inventory 存在 (且理論上不能變負? 視策略而定,這裡假設允許變負或由 InventoryService 控管)
|
||||
// 若是增加庫存,若不存在需建立
|
||||
|
||||
$inventory = Inventory::firstOrNew([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
]);
|
||||
|
||||
// 如果是新建立的 object (id 為空),需要初始化 default
|
||||
if (!$inventory->exists) {
|
||||
// 繼承 Product 成本或預設 0 (簡化處理)
|
||||
$inventory->unit_cost = $item->product->cost ?? 0;
|
||||
$inventory->quantity = 0;
|
||||
}
|
||||
|
||||
$oldQty = $inventory->quantity;
|
||||
$newQty = $oldQty + $item->adjust_qty;
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
// 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權)
|
||||
$inventory->total_value = $newQty * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 建立 Transaction
|
||||
$inventory->transactions()->create([
|
||||
'type' => '庫存調整',
|
||||
'quantity' => $item->adjust_qty,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $oldQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$doc->update([
|
||||
'status' => 'posted',
|
||||
'posted_at' => now(),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
|
||||
// 4. 若關聯盤點單,連動更新盤點單狀態
|
||||
if ($doc->count_doc_id) {
|
||||
InventoryCountDoc::where('id', $doc->count_doc_id)->update([
|
||||
'status' => 'adjusted'
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作廢 (Void)
|
||||
*/
|
||||
public function void(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$doc->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
109
app/Modules/Inventory/Services/CountService.php
Normal file
109
app/Modules/Inventory/Services/CountService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryCountItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CountService
|
||||
{
|
||||
/**
|
||||
* 建立新的盤點單並執行快照
|
||||
*/
|
||||
public function createDoc(string $warehouseId, string $remarks = null, int $userId): InventoryCountDoc
|
||||
{
|
||||
return DB::transaction(function () use ($warehouseId, $remarks, $userId) {
|
||||
$doc = InventoryCountDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return $doc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行快照:鎖定當前庫存量
|
||||
*/
|
||||
public function snapshot(InventoryCountDoc $doc): void
|
||||
{
|
||||
DB::transaction(function () use ($doc) {
|
||||
// 清除舊的 items (如果有)
|
||||
$doc->items()->delete();
|
||||
|
||||
// 取得該倉庫所有庫存 (包含 quantity = 0 但未軟刪除的)
|
||||
// 這裡可以根據需求決定是否要過濾掉 0 庫存,通常盤點單會希望能看到所有 "帳上有紀錄" 的東西
|
||||
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
foreach ($inventories as $inv) {
|
||||
$items[] = [
|
||||
'count_doc_id' => $doc->id,
|
||||
'product_id' => $inv->product_id,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'system_qty' => $inv->quantity,
|
||||
'counted_qty' => null, // 預設未盤點
|
||||
'diff_qty' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($items)) {
|
||||
InventoryCountItem::insert($items);
|
||||
}
|
||||
|
||||
$doc->update([
|
||||
'status' => 'counting',
|
||||
'snapshot_date' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成盤點:過帳差異
|
||||
*/
|
||||
public function complete(InventoryCountDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
// 僅更新單據狀態為「已完成」,不執行庫存入庫/調整
|
||||
// 盤點單僅作為記錄,後續調整由盤調單 (AdjustDoc) 執行
|
||||
$doc->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'completed_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新盤點數量
|
||||
*/
|
||||
public function updateCount(InventoryCountDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
foreach ($itemsData as $data) {
|
||||
$item = $doc->items()->find($data['id']);
|
||||
if ($item) {
|
||||
$countedQty = $data['counted_qty'];
|
||||
$diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0;
|
||||
|
||||
$item->update([
|
||||
'counted_qty' => $countedQty,
|
||||
'diff_qty' => $diff,
|
||||
'notes' => $data['notes'] ?? $item->notes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
109
app/Modules/Inventory/Services/GoodsReceiptService.php
Normal file
109
app/Modules/Inventory/Services/GoodsReceiptService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GoodsReceiptService
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Goods Receipt and process inventory.
|
||||
*
|
||||
* @param array $data
|
||||
* @return GoodsReceipt
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function store(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// 1. Generate Code
|
||||
$data['code'] = $this->generateCode($data['received_date']);
|
||||
$data['user_id'] = auth()->id();
|
||||
$data['status'] = 'completed'; // Direct completion for now
|
||||
|
||||
// 2. Create Header
|
||||
$goodsReceipt = GoodsReceipt::create($data);
|
||||
|
||||
// 3. Process Items
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// Create GR Item
|
||||
$grItem = new GoodsReceiptItem([
|
||||
'product_id' => $itemData['product_id'],
|
||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||
'quantity_received' => $itemData['quantity_received'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
$goodsReceipt->items()->save($grItem);
|
||||
|
||||
// 4. Update Inventory
|
||||
$reason = match($goodsReceipt->type) {
|
||||
'standard' => '採購進貨',
|
||||
'miscellaneous' => '雜項入庫',
|
||||
'other' => '其他入庫',
|
||||
default => '進貨入庫',
|
||||
};
|
||||
|
||||
$this->inventoryService->createInventoryRecord([
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'product_id' => $grItem->product_id,
|
||||
'quantity' => $grItem->quantity_received,
|
||||
'unit_cost' => $grItem->unit_price,
|
||||
'batch_number' => $grItem->batch_number,
|
||||
'expiry_date' => $grItem->expiry_date,
|
||||
'reason' => $reason,
|
||||
'reference_type' => GoodsReceipt::class,
|
||||
'reference_id' => $goodsReceipt->id,
|
||||
'source_purchase_order_id' => $goodsReceipt->purchase_order_id,
|
||||
'arrival_date' => $goodsReceipt->received_date,
|
||||
]);
|
||||
|
||||
// 5. Update PO if linked and type is standard
|
||||
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) {
|
||||
$this->procurementService->updateReceivedQuantity(
|
||||
$grItem->purchase_order_item_id,
|
||||
$grItem->quantity_received
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $goodsReceipt;
|
||||
});
|
||||
}
|
||||
|
||||
private function generateCode(string $date)
|
||||
{
|
||||
// Format: GR + YYYYMMDD + NNN
|
||||
$prefix = 'GR' . date('Ymd', strtotime($date));
|
||||
|
||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||
->orderBy('id', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($last) {
|
||||
$seq = intval(substr($last->code, -3)) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getAllProducts()
|
||||
{
|
||||
return Product::with(['baseUnit'])->get();
|
||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getUnits()
|
||||
@@ -32,17 +32,17 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getProduct(int $id)
|
||||
{
|
||||
return Product::find($id);
|
||||
return Product::with(['baseUnit', 'largeUnit'])->find($id);
|
||||
}
|
||||
|
||||
public function getProductsByIds(array $ids)
|
||||
{
|
||||
return Product::whereIn('id', $ids)->get();
|
||||
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getProductsByName(string $name)
|
||||
{
|
||||
return Product::where('name', 'like', "%{$name}%")->get();
|
||||
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getWarehouse(int $id)
|
||||
|
||||
152
app/Modules/Inventory/Services/TransferService.php
Normal file
152
app/Modules/Inventory/Services/TransferService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
/**
|
||||
* 建立調撥單草稿
|
||||
*/
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder
|
||||
{
|
||||
return InventoryTransferOrder::create([
|
||||
'from_warehouse_id' => $fromWarehouseId,
|
||||
'to_warehouse_id' => $toWarehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調撥單明細
|
||||
*/
|
||||
public function updateItems(InventoryTransferOrder $order, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($order, $itemsData) {
|
||||
$order->items()->delete();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
$order->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的)
|
||||
*/
|
||||
public function post(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$fromWarehouse = $order->fromWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 處理來源倉 (扣除)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足"],
|
||||
]);
|
||||
}
|
||||
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
// 更新總值 (假設成本不變)
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源交易
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '調撥出庫',
|
||||
'quantity' => -$item->quantity,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
// 2. 處理目的倉 (增加)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $order->to_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'total_value' => 0,
|
||||
// 繼承其他屬性
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
);
|
||||
|
||||
// 若是新建立的,且成本為0,確保繼承成本
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
||||
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
||||
$targetInventory->save();
|
||||
|
||||
// 記錄目的交易
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '調撥入庫',
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'posted_at' => now(),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function void(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$order->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,52 @@ interface ProcurementServiceInterface
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardStats(): array;
|
||||
|
||||
/**
|
||||
* Update received quantity for a PO item.
|
||||
*
|
||||
* @param int $poItemId
|
||||
* @param float $quantity
|
||||
* @return void
|
||||
*/
|
||||
public function updateReceivedQuantity(int $poItemId, float $quantity): void;
|
||||
|
||||
/**
|
||||
* Search pending or partial purchase orders.
|
||||
*
|
||||
* @param string $query
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchPendingPurchaseOrders(string $query): Collection;
|
||||
|
||||
/**
|
||||
* Search vendors by name or code.
|
||||
*
|
||||
* @param string $query
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchVendors(string $query): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有待進貨的採購單列表(不需搜尋條件)。
|
||||
* 用於進貨單頁面直接顯示可選擇的採購單。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPendingPurchaseOrders(): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有廠商列表。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllVendors(): Collection;
|
||||
|
||||
/**
|
||||
* Get vendors by multiple IDs.
|
||||
*
|
||||
* @param array $ids
|
||||
* @return Collection
|
||||
*/
|
||||
public function getVendorsByIds(array $ids): Collection;
|
||||
}
|
||||
|
||||
@@ -187,9 +187,10 @@ class PurchaseOrderController extends Controller
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// 生成單號:YYYYMMDD001
|
||||
// 生成單號:POYYYYMMDD001
|
||||
$today = now()->format('Ymd');
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
|
||||
$prefix = 'PO' . $today;
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
||||
->lockForUpdate() // 鎖定以避免並發衝突
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
@@ -201,7 +202,7 @@ class PurchaseOrderController extends Controller
|
||||
} else {
|
||||
$sequence = '001';
|
||||
}
|
||||
$code = $today . $sequence;
|
||||
$code = $prefix . $sequence;
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
@@ -419,7 +420,7 @@ class PurchaseOrderController extends Controller
|
||||
'order_date' => 'required|date', // 新增驗證
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
||||
'status' => 'required|string|in:draft,pending,approved,partial,completed,closed,cancelled',
|
||||
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||
'invoice_date' => 'nullable|date',
|
||||
'invoice_amount' => 'nullable|numeric|min:0',
|
||||
@@ -476,14 +477,21 @@ class PurchaseOrderController extends Controller
|
||||
$order->saveQuietly();
|
||||
|
||||
// 2. 捕捉包含商品名稱的舊項目以進行比對
|
||||
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
$oldItemsCollection = $order->items()->get();
|
||||
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
|
||||
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
|
||||
// 注意:單位的獲取可能也需要透過 InventoryService,但目前假設單位的關聯是合法的(如果在同一模組)
|
||||
// 如果單位也在不同模組,則需要另外處理。這裡暫時假設可以動手水和一下基本單位名稱。
|
||||
|
||||
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
|
||||
$product = $oldProducts->get($item->product_id);
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product?->name,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'unit_name' => 'N/A', // 簡化處理,或可透過服務獲取
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
@@ -513,14 +521,19 @@ class PurchaseOrderController extends Controller
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// 重新獲取新項目以確保擁有最新的關聯
|
||||
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
// 重新獲取新項目並水和產品資料
|
||||
$newItemsCollection = $order->items()->get();
|
||||
$newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray();
|
||||
$newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id');
|
||||
|
||||
$newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) {
|
||||
$product = $newProducts->get($item->product_id);
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product?->name,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'unit_name' => 'N/A',
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
@@ -95,14 +95,15 @@ class VendorController extends Controller
|
||||
if (!$product) return null;
|
||||
|
||||
return (object) [
|
||||
'id' => (string) $pivot->id,
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'unit' => $product->baseUnit?->name ?? 'N/A',
|
||||
'baseUnit' => $product->baseUnit?->name,
|
||||
'largeUnit' => $product->largeUnit?->name,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) $pivot->last_price,
|
||||
'id' => (string) $product->id, // Frontend expects product ID here as p.id
|
||||
'name' => $product->name,
|
||||
'baseUnit' => $product->baseUnit ? (object)['name' => $product->baseUnit->name] : null,
|
||||
'largeUnit' => $product->largeUnit ? (object)['name' => $product->largeUnit->name] : null,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'purchase_unit' => $product->purchaseUnit?->name,
|
||||
'pivot' => (object) [
|
||||
'last_price' => (float) $pivot->last_price,
|
||||
],
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
@@ -119,7 +120,7 @@ class VendorController extends Controller
|
||||
'email' => $vendor->email,
|
||||
'address' => $vendor->address,
|
||||
'remark' => $vendor->remark,
|
||||
'supplyProducts' => $supplyProducts,
|
||||
'products' => $supplyProducts, // Changed from supplyProducts to products
|
||||
];
|
||||
|
||||
return Inertia::render('Vendor/Show', [
|
||||
|
||||
@@ -29,4 +29,74 @@ class ProcurementService implements ProcurementServiceInterface
|
||||
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function updateReceivedQuantity(int $poItemId, float $quantity): void
|
||||
{
|
||||
$item = \App\Modules\Procurement\Models\PurchaseOrderItem::findOrFail($poItemId);
|
||||
$item->increment('received_quantity', $quantity);
|
||||
$item->refresh();
|
||||
|
||||
// Check PO status
|
||||
$po = $item->purchaseOrder;
|
||||
|
||||
// Load items to check completion
|
||||
$po->load('items');
|
||||
|
||||
$allReceived = $po->items->every(function ($i) {
|
||||
return $i->received_quantity >= $i->quantity;
|
||||
});
|
||||
|
||||
$anyReceived = $po->items->contains(function ($i) {
|
||||
return $i->received_quantity > 0;
|
||||
});
|
||||
|
||||
if ($allReceived) {
|
||||
$po->status = 'completed'; // or 'received' based on workflow
|
||||
} elseif ($anyReceived) {
|
||||
$po->status = 'partial';
|
||||
}
|
||||
|
||||
$po->save();
|
||||
}
|
||||
|
||||
public function searchPendingPurchaseOrders(string $query): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where(function($q) use ($query) {
|
||||
$q->where('code', 'like', "%{$query}%")
|
||||
->orWhereHas('vendor', function($vq) use ($query) {
|
||||
$vq->where('name', 'like', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function searchVendors(string $query): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::where('name', 'like', "%{$query}%")
|
||||
->orWhere('code', 'like', "%{$query}%")
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getPendingPurchaseOrders(): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(50)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getAllVendors(): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::orderBy('name')->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getVendorsByIds(array $ids): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,28 @@ use App\Modules\Production\Models\ProductionOrder;
|
||||
use App\Modules\Production\Models\ProductionOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
|
||||
class ProductionOrderController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $coreService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
CoreServiceInterface $coreService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->coreService = $coreService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,9 +45,6 @@ class ProductionOrderController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('code', 'like', "%{$search}%")
|
||||
->orWhere('output_batch_number', 'like', "%{$search}%");
|
||||
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
|
||||
$q->where('code', 'like', "%{$search}%")
|
||||
->orWhere('output_batch_number', 'like', "%{$search}%");
|
||||
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
|
||||
@@ -205,15 +210,29 @@ class ProductionOrderController extends Controller
|
||||
// 手動水和明細資料
|
||||
$items = $productionOrder->items;
|
||||
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
|
||||
|
||||
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
|
||||
$inventories = $this->inventoryService->getInventoriesByIds(
|
||||
$inventoryIds,
|
||||
['product.baseUnit', 'sourcePurchaseOrder.vendor']
|
||||
['product.baseUnit']
|
||||
)->keyBy('id');
|
||||
|
||||
// 手動載入 Purchase Orders
|
||||
$poIds = $inventories->pluck('source_purchase_order_id')->unique()->filter()->toArray();
|
||||
$purchaseOrders = collect();
|
||||
if (!empty($poIds)) {
|
||||
$purchaseOrders = $this->procurementService->getPurchaseOrdersByIds($poIds, ['vendor'])->keyBy('id');
|
||||
}
|
||||
|
||||
$units = $this->inventoryService->getUnits()->keyBy('id');
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item->inventory = $inventories->get($item->inventory_id);
|
||||
if ($item->inventory) {
|
||||
// 手動掛載 PO
|
||||
$poId = $item->inventory->source_purchase_order_id;
|
||||
$item->inventory->sourcePurchaseOrder = $purchaseOrders->get($poId);
|
||||
}
|
||||
$item->unit = $units->get($item->unit_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -188,4 +188,118 @@ class RecipeController extends Controller
|
||||
$recipe->delete();
|
||||
return redirect()->back()->with('success', '配方已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取配方詳細資料 (API)
|
||||
*/
|
||||
/**
|
||||
* 獲取配方詳細資料 (API)
|
||||
*/
|
||||
public function show(Recipe $recipe)
|
||||
{
|
||||
// Manual Hydration for strict modularity
|
||||
$recipe->product = $this->inventoryService->getProduct($recipe->product_id);
|
||||
|
||||
$items = $recipe->items;
|
||||
$productIds = $items->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
$units = $this->inventoryService->getUnits()->keyBy('id');
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item->product = $products->get($item->product_id);
|
||||
$item->unit = $units->get($item->unit_id);
|
||||
}
|
||||
|
||||
return response()->json($recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取商品最新有效配方 (API)
|
||||
*/
|
||||
public function getLatestByProduct($productId)
|
||||
{
|
||||
// 放寬條件,只要 product_id 相符就抓最新的
|
||||
$recipe = Recipe::where('product_id', (int)$productId)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
|
||||
if (!$recipe) {
|
||||
return response()->json(null);
|
||||
}
|
||||
|
||||
// Load items with product info
|
||||
$items = $recipe->items;
|
||||
$productIds = $items->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
$formattedItems = $items->map(function ($item) use ($products) {
|
||||
$product = $products->get($item->product_id);
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product->name ?? '未知商品',
|
||||
'product_code' => $product->code ?? '',
|
||||
'quantity' => $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $product->baseUnit->name ?? '',
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'id' => $recipe->id,
|
||||
'name' => $recipe->name,
|
||||
'code' => $recipe->code,
|
||||
'yield_quantity' => $recipe->yield_quantity,
|
||||
'items' => $formattedItems,
|
||||
]);
|
||||
}
|
||||
/**
|
||||
* 獲取商品所有有效配方列表 (API)
|
||||
*/
|
||||
public function getByProduct($productId)
|
||||
{
|
||||
$recipes = Recipe::where('product_id', (int)$productId)
|
||||
->where('is_active', true)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($recipes->isEmpty()) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
// 預先載入必要的關聯與數據
|
||||
// 為了效能,我們只在列表顯示基本資訊,詳細 Item 資料等選中後再透過 getLatestByProduct (或是重構為 getDetails) 獲取
|
||||
// 不過為了前端方便,若配方不多,直接回傳完整結構也可以。
|
||||
// 這裡選擇回傳完整結構,因為配方通常不會太多
|
||||
|
||||
$recipes->load('items');
|
||||
|
||||
// 收集所有 recipe items 中的 product ids
|
||||
$allProductIds = $recipes->pluck('items')->flatten()->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($allProductIds)->keyBy('id');
|
||||
|
||||
$result = $recipes->map(function ($recipe) use ($products) {
|
||||
$formattedItems = $recipe->items->map(function ($item) use ($products) {
|
||||
$product = $products->get($item->product_id);
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product->name ?? '未知商品',
|
||||
'product_code' => $product->code ?? '',
|
||||
'quantity' => $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $product->baseUnit->name ?? '',
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $recipe->id,
|
||||
'name' => $recipe->name,
|
||||
'code' => $recipe->code,
|
||||
'yield_quantity' => $recipe->yield_quantity,
|
||||
'items' => $formattedItems,
|
||||
'created_at' => $recipe->created_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,5 +27,13 @@ class RecipeItem extends Model
|
||||
return $this->belongsTo(Recipe::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Inventory\Models\Product::class);
|
||||
}
|
||||
|
||||
public function unit()
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,10 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/api/production/warehouses/{warehouse}/inventories', [ProductionOrderController::class, 'getWarehouseInventories'])
|
||||
->middleware('permission:production_orders.create')
|
||||
->name('api.production.warehouses.inventories');
|
||||
|
||||
Route::get('/api/production/recipes/latest-by-product/{productId}', [RecipeController::class, 'getLatestByProduct'])
|
||||
->name('api.production.recipes.latest-by-product');
|
||||
|
||||
Route::get('/api/production/recipes/by-product/{productId}', [RecipeController::class, 'getByProduct'])
|
||||
->name('api.production.recipes.by-product');
|
||||
});
|
||||
|
||||
@@ -18,9 +18,17 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// 如果是在正式環境,強制轉為 https
|
||||
if (config('app.env') === 'production') {
|
||||
// 強制 HTTPS 檢測邏輯 (包含 Cloudflare/Load Balancer 支援)
|
||||
$isHttps = $this->app->environment('production')
|
||||
|| str_contains(config('app.url'), 'https')
|
||||
|| request()->header('x-forwarded-proto') === 'https'
|
||||
|| request()->server('HTTPS') === 'on';
|
||||
|
||||
if ($isHttps) {
|
||||
URL::forceScheme('https');
|
||||
|
||||
// 強制讓 Request 物件認為自己是安全連線 (解決 Paginator 或 Request::secure() 判斷問題)
|
||||
request()->server->set('HTTPS', 'on');
|
||||
}
|
||||
|
||||
// 隱含授權:讓 "super-admin" 角色擁有所有權限
|
||||
|
||||
@@ -8,8 +8,6 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Spatie\Permission\Exceptions\UnauthorizedException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// 信任所有代理(用於反向代理環境)
|
||||
TrustProxies::at('*');
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -18,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// 信任所有代理(用於反向代理環境)
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
// Tenancy 必須最先執行,確保資料庫連線在 Session 讀取之前建立
|
||||
$middleware->web(prepend: [
|
||||
\App\Http\Middleware\UniversalTenancy::class,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"spatie/laravel-activitylog": "^4.10",
|
||||
"spatie/laravel-permission": "^6.24",
|
||||
"stancl/jobpipeline": "^1.8",
|
||||
@@ -93,4 +94,4 @@
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
}
|
||||
|
||||
591
composer.lock
generated
591
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "46092572c41c587bf3e7fc53465e5b56",
|
||||
"content-hash": "b3cbace7e72a7a68b5aefdd82bea205a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -135,6 +135,162 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/semver",
|
||||
"version": "3.4.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/semver.git",
|
||||
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^5.3.2 || ^7.0 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.11",
|
||||
"symfony/phpunit-bridge": "^3 || ^7"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Semver\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nils Adermann",
|
||||
"email": "naderman@naderman.de",
|
||||
"homepage": "http://www.naderman.de"
|
||||
},
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
},
|
||||
{
|
||||
"name": "Rob Bast",
|
||||
"email": "rob.bast@gmail.com",
|
||||
"homepage": "http://robbast.nl"
|
||||
}
|
||||
],
|
||||
"description": "Semver library that offers utilities, version constraint parsing and validation.",
|
||||
"keywords": [
|
||||
"semantic",
|
||||
"semver",
|
||||
"validation",
|
||||
"versioning"
|
||||
],
|
||||
"support": {
|
||||
"irc": "ircs://irc.libera.chat:6697/composer",
|
||||
"issues": "https://github.com/composer/semver/issues",
|
||||
"source": "https://github.com/composer/semver/tree/3.4.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-20T19:15:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
@@ -508,6 +664,67 @@
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ezyang/htmlpurifier",
|
||||
"version": "v4.19.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ezyang/htmlpurifier.git",
|
||||
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"cerdic/css-tidy": "^1.7 || ^2.0",
|
||||
"simpletest/simpletest": "dev-master"
|
||||
},
|
||||
"suggest": {
|
||||
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
|
||||
"ext-bcmath": "Used for unit conversion and imagecrash protection",
|
||||
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
|
||||
"ext-tidy": "Used for pretty-printing HTML"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"library/HTMLPurifier.composer.php"
|
||||
],
|
||||
"psr-0": {
|
||||
"HTMLPurifier": "library/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/library/HTMLPurifier/Language/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Edward Z. Yang",
|
||||
"email": "admin@htmlpurifier.org",
|
||||
"homepage": "http://ezyang.com"
|
||||
}
|
||||
],
|
||||
"description": "Standards compliant HTML filter written in PHP",
|
||||
"homepage": "http://htmlpurifier.org/",
|
||||
"keywords": [
|
||||
"html"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/ezyang/htmlpurifier/issues",
|
||||
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
|
||||
},
|
||||
"time": "2025-10-17T16:34:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "facade/ignition-contracts",
|
||||
"version": "1.0.2",
|
||||
@@ -2142,6 +2359,272 @@
|
||||
],
|
||||
"time": "2025-12-07T16:03:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maatwebsite/excel",
|
||||
"version": "3.1.67",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
|
||||
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/semver": "^3.3",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
|
||||
"php": "^7.0||^8.0",
|
||||
"phpoffice/phpspreadsheet": "^1.30.0",
|
||||
"psr/simple-cache": "^1.0||^2.0||^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
|
||||
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
|
||||
"predis/predis": "^1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
|
||||
},
|
||||
"providers": [
|
||||
"Maatwebsite\\Excel\\ExcelServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Maatwebsite\\Excel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Patrick Brouwers",
|
||||
"email": "patrick@spartner.nl"
|
||||
}
|
||||
],
|
||||
"description": "Supercharged Excel exports and imports in Laravel",
|
||||
"keywords": [
|
||||
"PHPExcel",
|
||||
"batch",
|
||||
"csv",
|
||||
"excel",
|
||||
"export",
|
||||
"import",
|
||||
"laravel",
|
||||
"php",
|
||||
"phpspreadsheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
|
||||
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://laravel-excel.com/commercial-support",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/patrickbrouwers",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-26T09:13:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-10T09:58:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Complex\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@lange.demon.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with complex numbers",
|
||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||
"keywords": [
|
||||
"complex",
|
||||
"mathematics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||
},
|
||||
"time": "2022-12-06T16:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/matrix",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpdocumentor/phpdocumentor": "2.*",
|
||||
"phploc/phploc": "^4.0",
|
||||
"phpmd/phpmd": "2.*",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"sebastian/phpcpd": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Matrix\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@demon-angel.eu"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with matrices",
|
||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||
"keywords": [
|
||||
"mathematics",
|
||||
"matrix",
|
||||
"vector"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.9.0",
|
||||
@@ -2649,6 +3132,112 @@
|
||||
],
|
||||
"time": "2025-11-20T02:34:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "1.30.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "2f39286e0136673778b7a142b3f0d141e43d1714"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2f39286e0136673778b7a142b3f0d141e43d1714",
|
||||
"reference": "2f39286e0136673778b7a142b3f0d141e43d1714",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/pcre": "^1||^2||^3",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*",
|
||||
"ezyang/htmlpurifier": "^4.15",
|
||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||
"markbaker/complex": "^3.0",
|
||||
"markbaker/matrix": "^3.0",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"mitoteam/jpgraph": "^10.3",
|
||||
"mpdf/mpdf": "^8.1.1",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpstan/phpstan": "^1.1",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"phpunit/phpunit": "^8.5 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
},
|
||||
"suggest": {
|
||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||
"ext-intl": "PHP Internationalization Functions",
|
||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maarten Balliauw",
|
||||
"homepage": "https://blog.maartenballiauw.be"
|
||||
},
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"homepage": "https://markbakeruk.net"
|
||||
},
|
||||
{
|
||||
"name": "Franck Lefevre",
|
||||
"homepage": "https://rootslabs.net"
|
||||
},
|
||||
{
|
||||
"name": "Erik Tilt"
|
||||
},
|
||||
{
|
||||
"name": "Adrien Crivelli"
|
||||
}
|
||||
],
|
||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||
"keywords": [
|
||||
"OpenXML",
|
||||
"excel",
|
||||
"gnumeric",
|
||||
"ods",
|
||||
"php",
|
||||
"spreadsheet",
|
||||
"xls",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.0"
|
||||
},
|
||||
"time": "2025-08-10T06:28:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.4",
|
||||
|
||||
380
config/excel.php
Normal file
380
config/excel.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
use Maatwebsite\Excel\Excel;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Csv;
|
||||
|
||||
return [
|
||||
'exports' => [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chunk size
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using FromQuery, the query is automatically chunked.
|
||||
| Here you can specify how big the chunk should be.
|
||||
|
|
||||
*/
|
||||
'chunk_size' => 1000,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Pre-calculate formulas during export
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'pre_calculate_formulas' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Enable strict null comparison
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When enabling strict null comparison empty cells ('') will
|
||||
| be added to the sheet.
|
||||
*/
|
||||
'strict_null_comparison' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CSV Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure e.g. delimiter, enclosure and line ending for CSV exports.
|
||||
|
|
||||
*/
|
||||
'csv' => [
|
||||
'delimiter' => ',',
|
||||
'enclosure' => '"',
|
||||
'line_ending' => PHP_EOL,
|
||||
'use_bom' => false,
|
||||
'include_separator_line' => false,
|
||||
'excel_compatibility' => false,
|
||||
'output_encoding' => '',
|
||||
'test_auto_detect' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Worksheet properties
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure e.g. default title, creator, subject,...
|
||||
|
|
||||
*/
|
||||
'properties' => [
|
||||
'creator' => '',
|
||||
'lastModifiedBy' => '',
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'subject' => '',
|
||||
'keywords' => '',
|
||||
'category' => '',
|
||||
'manager' => '',
|
||||
'company' => '',
|
||||
],
|
||||
],
|
||||
|
||||
'imports' => [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Read Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When dealing with imports, you might only be interested in the
|
||||
| data that the sheet exists. By default we ignore all styles,
|
||||
| however if you want to do some logic based on style data
|
||||
| you can enable it by setting read_only to false.
|
||||
|
|
||||
*/
|
||||
'read_only' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ignore Empty
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When dealing with imports, you might be interested in ignoring
|
||||
| rows that have null values or empty strings. By default rows
|
||||
| containing empty strings or empty values are not ignored but can be
|
||||
| ignored by enabling the setting ignore_empty to true.
|
||||
|
|
||||
*/
|
||||
'ignore_empty' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Heading Row Formatter
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure the heading row formatter.
|
||||
| Available options: none|slug|custom
|
||||
|
|
||||
*/
|
||||
'heading_row' => [
|
||||
'formatter' => 'slug',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CSV Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure e.g. delimiter, enclosure and line ending for CSV imports.
|
||||
|
|
||||
*/
|
||||
'csv' => [
|
||||
'delimiter' => null,
|
||||
'enclosure' => '"',
|
||||
'escape_character' => '\\',
|
||||
'contiguous' => false,
|
||||
'input_encoding' => Csv::GUESS_ENCODING,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Worksheet properties
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure e.g. default title, creator, subject,...
|
||||
|
|
||||
*/
|
||||
'properties' => [
|
||||
'creator' => '',
|
||||
'lastModifiedBy' => '',
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'subject' => '',
|
||||
'keywords' => '',
|
||||
'category' => '',
|
||||
'manager' => '',
|
||||
'company' => '',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cell Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure middleware that is executed on getting a cell value
|
||||
|
|
||||
*/
|
||||
'cells' => [
|
||||
'middleware' => [
|
||||
//\Maatwebsite\Excel\Middleware\TrimCellValue::class,
|
||||
//\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class,
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Extension detector
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure here which writer/reader type should be used when the package
|
||||
| needs to guess the correct type based on the extension alone.
|
||||
|
|
||||
*/
|
||||
'extension_detector' => [
|
||||
'xlsx' => Excel::XLSX,
|
||||
'xlsm' => Excel::XLSX,
|
||||
'xltx' => Excel::XLSX,
|
||||
'xltm' => Excel::XLSX,
|
||||
'xls' => Excel::XLS,
|
||||
'xlt' => Excel::XLS,
|
||||
'ods' => Excel::ODS,
|
||||
'ots' => Excel::ODS,
|
||||
'slk' => Excel::SLK,
|
||||
'xml' => Excel::XML,
|
||||
'gnumeric' => Excel::GNUMERIC,
|
||||
'htm' => Excel::HTML,
|
||||
'html' => Excel::HTML,
|
||||
'csv' => Excel::CSV,
|
||||
'tsv' => Excel::TSV,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| PDF Extension
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure here which Pdf driver should be used by default.
|
||||
| Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF
|
||||
|
|
||||
*/
|
||||
'pdf' => Excel::DOMPDF,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Value Binder
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| PhpSpreadsheet offers a way to hook into the process of a value being
|
||||
| written to a cell. In there some assumptions are made on how the
|
||||
| value should be formatted. If you want to change those defaults,
|
||||
| you can implement your own default value binder.
|
||||
|
|
||||
| Possible value binders:
|
||||
|
|
||||
| [x] Maatwebsite\Excel\DefaultValueBinder::class
|
||||
| [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class
|
||||
| [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class
|
||||
|
|
||||
*/
|
||||
'value_binder' => [
|
||||
'default' => Maatwebsite\Excel\DefaultValueBinder::class,
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default cell caching driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default PhpSpreadsheet keeps all cell values in memory, however when
|
||||
| dealing with large files, this might result into memory issues. If you
|
||||
| want to mitigate that, you can configure a cell caching driver here.
|
||||
| When using the illuminate driver, it will store each value in the
|
||||
| cache store. This can slow down the process, because it needs to
|
||||
| store each value. You can use the "batch" store if you want to
|
||||
| only persist to the store when the memory limit is reached.
|
||||
|
|
||||
| Drivers: memory|illuminate|batch
|
||||
|
|
||||
*/
|
||||
'driver' => 'memory',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Batch memory caching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When dealing with the "batch" caching driver, it will only
|
||||
| persist to the store when the memory limit is reached.
|
||||
| Here you can tweak the memory limit to your liking.
|
||||
|
|
||||
*/
|
||||
'batch' => [
|
||||
'memory_limit' => 60000,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Illuminate cache
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "illuminate" caching driver, it will automatically use
|
||||
| your default cache store. However if you prefer to have the cell
|
||||
| cache on a separate store, you can configure the store name here.
|
||||
| You can use any store defined in your cache config. When leaving
|
||||
| at "null" it will use the default store.
|
||||
|
|
||||
*/
|
||||
'illuminate' => [
|
||||
'store' => null,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Time-to-live (TTL)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The TTL of items written to cache. If you want to keep the items cached
|
||||
| indefinitely, set this to null. Otherwise, set a number of seconds,
|
||||
| a \DateInterval, or a callable.
|
||||
|
|
||||
| Allowable types: callable|\DateInterval|int|null
|
||||
|
|
||||
*/
|
||||
'default_ttl' => 10800,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Transaction Handler
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default the import is wrapped in a transaction. This is useful
|
||||
| for when an import may fail and you want to retry it. With the
|
||||
| transactions, the previous import gets rolled-back.
|
||||
|
|
||||
| You can disable the transaction handler by setting this to null.
|
||||
| Or you can choose a custom made transaction handler here.
|
||||
|
|
||||
| Supported handlers: null|db
|
||||
|
|
||||
*/
|
||||
'transactions' => [
|
||||
'handler' => 'db',
|
||||
'db' => [
|
||||
'connection' => null,
|
||||
],
|
||||
],
|
||||
|
||||
'temporary_files' => [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Local Temporary Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When exporting and importing files, we use a temporary file, before
|
||||
| storing reading or downloading. Here you can customize that path.
|
||||
| permissions is an array with the permission flags for the directory (dir)
|
||||
| and the create file (file).
|
||||
|
|
||||
*/
|
||||
'local_path' => storage_path('framework/cache/laravel-excel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Local Temporary Path Permissions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Permissions is an array with the permission flags for the directory (dir)
|
||||
| and the create file (file).
|
||||
| If omitted the default permissions of the filesystem will be used.
|
||||
|
|
||||
*/
|
||||
'local_permissions' => [
|
||||
// 'dir' => 0755,
|
||||
// 'file' => 0644,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Remote Temporary Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When dealing with a multi server setup with queues in which you
|
||||
| cannot rely on having a shared local temporary path, you might
|
||||
| want to store the temporary file on a shared disk. During the
|
||||
| queue executing, we'll retrieve the temporary file from that
|
||||
| location instead. When left to null, it will always use
|
||||
| the local path. This setting only has effect when using
|
||||
| in conjunction with queued imports and exports.
|
||||
|
|
||||
*/
|
||||
'remote_disk' => null,
|
||||
'remote_prefix' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Force Resync
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When dealing with a multi server setup as above, it's possible
|
||||
| for the clean up that occurs after entire queue has been run to only
|
||||
| cleanup the server that the last AfterImportJob runs on. The rest of the server
|
||||
| would still have the local temporary file stored on it. In this case your
|
||||
| local storage limits can be exceeded and future imports won't be processed.
|
||||
| To mitigate this you can set this config value to be true, so that after every
|
||||
| queued chunk is processed the local temporary file is deleted on the server that
|
||||
| processed it.
|
||||
|
|
||||
*/
|
||||
'force_resync_remote' => null,
|
||||
],
|
||||
];
|
||||
@@ -129,7 +129,7 @@ return [
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'_v2_session'
|
||||
),
|
||||
|
||||
/*
|
||||
|
||||
@@ -204,6 +204,6 @@ return [
|
||||
*/
|
||||
'seeder_parameters' => [
|
||||
'--class' => 'TenantDatabaseSeeder', // 租戶專用 seeder
|
||||
// '--force' => true, // This needs to be true to seed tenant databases in production
|
||||
'--force' => true, // 強制在正式環境執行 Seeder
|
||||
],
|
||||
];
|
||||
|
||||
@@ -37,6 +37,7 @@ class UserFactory extends Factory
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->create(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('log_name')->nullable();
|
||||
$table->text('description');
|
||||
$table->nullableMorphs('subject', 'subject');
|
||||
$table->nullableMorphs('causer', 'causer');
|
||||
$table->json('properties')->nullable();
|
||||
$table->timestamps();
|
||||
$table->index('log_name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddEventColumnToActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->string('event')->nullable()->after('subject_type');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->dropColumn('event');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddBatchUuidColumnToActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->uuid('batch_uuid')->nullable()->after('properties');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->dropColumn('batch_uuid');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
// 單欄索引:事件類型(高頻過濾條件)
|
||||
$table->index('event', 'idx_event');
|
||||
|
||||
// 單欄索引:批次 UUID(未來批次操作查詢)
|
||||
$table->index('batch_uuid', 'idx_batch_uuid');
|
||||
|
||||
// 複合索引 1:時間 + 事件類型(最常見的組合查詢)
|
||||
$table->index(['created_at', 'event'], 'idx_created_event');
|
||||
|
||||
// 複合索引 2:主體類型 + 主體 ID + 時間(查詢特定資源的操作歷史)
|
||||
$table->index(['subject_type', 'subject_id', 'created_at'], 'idx_subject_created');
|
||||
|
||||
// 複合索引 3:操作者 + 時間(查詢特定使用者的操作紀錄)
|
||||
$table->index(['causer_id', 'created_at'], 'idx_causer_created');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->dropIndex('idx_event');
|
||||
$table->dropIndex('idx_batch_uuid');
|
||||
$table->dropIndex('idx_created_event');
|
||||
$table->dropIndex('idx_subject_created');
|
||||
$table->dropIndex('idx_causer_created');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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('users', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(true)->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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('warehouses', function (Blueprint $table) {
|
||||
$table->dropColumn('is_sellable');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->boolean('is_sellable')->default(true)->after('description')->comment('是否可銷售');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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('goods_receipts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->index(); // GR 單號
|
||||
$table->foreignId('warehouse_id')->constrained()->onDelete('restrict');
|
||||
$table->foreignId('purchase_order_id')->nullable()->constrained()->onDelete('set null');
|
||||
$table->foreignId('vendor_id')->constrained()->onDelete('restrict'); // 關聯到 Inventory 模組內的 Vendor 邏輯或跨模組 ID (此處僅 FK 約束通常指向同一 DB 的 vendors 表)
|
||||
$table->date('received_date');
|
||||
$table->enum('status', ['draft', 'completed', 'cancelled'])->default('draft');
|
||||
$table->text('remarks')->nullable();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('restrict'); // 經辦人
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('goods_receipt_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('goods_receipt_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('product_id')->constrained()->onDelete('restrict');
|
||||
$table->foreignId('purchase_order_item_id')->nullable()->constrained()->onDelete('set null'); // 用於回寫 PO Item
|
||||
$table->decimal('quantity_received', 10, 2);
|
||||
$table->decimal('unit_price', 10, 2); // 暫定價格 (來自 PO)
|
||||
$table->decimal('total_amount', 12, 2); // 小計
|
||||
$table->string('batch_number')->nullable();
|
||||
$table->date('expiry_date')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('goods_receipt_items');
|
||||
Schema::dropIfExists('goods_receipts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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('goods_receipts', function (Blueprint $table) {
|
||||
$table->enum('type', ['standard', 'miscellaneous', 'other'])->default('standard')->after('warehouse_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('goods_receipts', function (Blueprint $table) {
|
||||
$table->dropColumn('type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Update old statuses to 'approved'
|
||||
DB::table('purchase_orders')
|
||||
->whereIn('status', ['processing', 'shipping', 'confirming'])
|
||||
->update(['status' => 'approved']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Cannot easily reverse without knowing original status,
|
||||
// but typically we can revert 'approved' back to 'processing' as a safeguard if needed,
|
||||
// or just leave it since 'approved' is broader.
|
||||
// For strict reversal, we might try to map back, but effectively this is a one-way consolidation.
|
||||
// We will leave it as is for down/safe side.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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('inventory_count_docs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('doc_no')->unique(); // 單號
|
||||
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->string('status')->default('draft'); // draft, counting, completed, cancelled
|
||||
$table->timestamp('snapshot_date')->nullable(); // 快照建立時間
|
||||
$table->timestamp('completed_at')->nullable(); // 完成時間
|
||||
$table->string('remarks')->nullable();
|
||||
|
||||
// 審核/建立資訊
|
||||
$table->foreignId('created_by')->constrained('users');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users');
|
||||
$table->foreignId('completed_by')->nullable()->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('inventory_count_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('count_doc_id')->constrained('inventory_count_docs')->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained('products');
|
||||
$table->string('batch_number')->nullable(); // 針對特定批號盤點
|
||||
|
||||
$table->decimal('system_qty', 10, 2)->default(0); // 系統帳面數量 (快照當下)
|
||||
$table->decimal('counted_qty', 10, 2)->nullable(); // 實盤數量
|
||||
$table->decimal('diff_qty', 10, 2)->default(0); // 差異 (實盤 - 系統)
|
||||
$table->string('notes')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_count_items');
|
||||
Schema::dropIfExists('inventory_count_docs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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('inventory_adjust_docs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('doc_no')->unique(); // 單號
|
||||
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->string('status')->default('draft'); // draft, posted, voided
|
||||
$table->string('reason')->nullable(); // 調整原因 (e.g. 報廢, 盤盈虧, 其他)
|
||||
$table->string('remarks')->nullable();
|
||||
|
||||
// 審核/建立資訊
|
||||
$table->timestamp('posted_at')->nullable(); // 過帳時間
|
||||
$table->foreignId('created_by')->constrained('users');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users');
|
||||
$table->foreignId('posted_by')->nullable()->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('inventory_adjust_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('adjust_doc_id')->constrained('inventory_adjust_docs')->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained('products');
|
||||
$table->string('batch_number')->nullable();
|
||||
|
||||
// 記錄當下 "調整前" 的庫存與成本 (參考用)
|
||||
$table->decimal('qty_before', 10, 2)->default(0);
|
||||
|
||||
// 實際調整的數量 (可以正負, 正=增加, 負=減少)
|
||||
$table->decimal('adjust_qty', 10, 2);
|
||||
|
||||
$table->string('notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_adjust_items');
|
||||
Schema::dropIfExists('inventory_adjust_docs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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('inventory_transfer_orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('doc_no')->unique();
|
||||
$table->foreignId('from_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->foreignId('to_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->string('status')->default('draft'); // draft, completed, voided
|
||||
$table->string('remarks')->nullable();
|
||||
|
||||
// 審核/建立資訊
|
||||
$table->timestamp('posted_at')->nullable(); // 過帳時間
|
||||
$table->foreignId('created_by')->constrained('users');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users');
|
||||
$table->foreignId('posted_by')->nullable()->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('inventory_transfer_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('transfer_order_id')->constrained('inventory_transfer_orders')->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained('products');
|
||||
$table->string('batch_number')->nullable();
|
||||
$table->decimal('quantity', 10, 2);
|
||||
$table->string('notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_transfer_items');
|
||||
Schema::dropIfExists('inventory_transfer_orders');
|
||||
}
|
||||
};
|
||||
@@ -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::table('inventory_adjust_docs', function (Blueprint $table) {
|
||||
$table->foreignId('count_doc_id')
|
||||
->after('doc_no')
|
||||
->nullable()
|
||||
->constrained('inventory_count_docs')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_adjust_docs', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('count_doc_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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('products', function (Blueprint $table) {
|
||||
$table->string('barcode')->nullable()->unique()->index()->after('code')->comment('條碼編號');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn('barcode');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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('users', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(true)->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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('products', function (Blueprint $table) {
|
||||
$table->string('location')->nullable()->after('specification')->comment('儲位註記');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn('location');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,11 @@ class CategorySeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
[
|
||||
'name' => '商品',
|
||||
'description' => '由原物料加工完成的成品',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => '原物料',
|
||||
'description' => '製作飲品或餐點的基礎原料',
|
||||
|
||||
@@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder
|
||||
'name' => '系統管理員',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -35,8 +35,43 @@ class PermissionSeeder extends Seeder
|
||||
// 庫存管理
|
||||
'inventory.view',
|
||||
'inventory.view_cost', // 查看成本與價值
|
||||
'inventory.adjust',
|
||||
'inventory.transfer',
|
||||
'inventory.delete',
|
||||
|
||||
// 庫存盤點 (Stock Counting)
|
||||
'inventory_count.view',
|
||||
'inventory_count.create',
|
||||
'inventory_count.edit',
|
||||
'inventory_count.delete',
|
||||
|
||||
// 庫存調整 (Stock Adjustment)
|
||||
'inventory_adjust.view',
|
||||
'inventory_adjust.create',
|
||||
'inventory_adjust.edit',
|
||||
'inventory_adjust.delete',
|
||||
|
||||
// 庫存調撥 (Stock Transfer)
|
||||
'inventory_transfer.view',
|
||||
'inventory_transfer.create',
|
||||
'inventory_transfer.edit',
|
||||
'inventory_transfer.delete',
|
||||
|
||||
// 進貨單管理
|
||||
'goods_receipts.view',
|
||||
'goods_receipts.create',
|
||||
'goods_receipts.edit',
|
||||
'goods_receipts.delete',
|
||||
|
||||
// 生產工單管理
|
||||
'production_orders.view',
|
||||
'production_orders.create',
|
||||
'production_orders.edit',
|
||||
'production_orders.delete',
|
||||
|
||||
// 配方管理
|
||||
'recipes.view',
|
||||
'recipes.create',
|
||||
'recipes.edit',
|
||||
'recipes.delete',
|
||||
|
||||
// 供應商管理
|
||||
'vendors.view',
|
||||
@@ -55,6 +90,7 @@ class PermissionSeeder extends Seeder
|
||||
'users.create',
|
||||
'users.edit',
|
||||
'users.delete',
|
||||
'users.activate', // 啟用/停用使用者
|
||||
|
||||
// 角色權限管理
|
||||
'roles.view',
|
||||
@@ -97,7 +133,13 @@ class PermissionSeeder extends Seeder
|
||||
'products.view', 'products.create', 'products.edit', 'products.delete',
|
||||
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||
'purchase_orders.delete', 'purchase_orders.publish',
|
||||
'inventory.view', 'inventory.view_cost', 'inventory.adjust', 'inventory.transfer',
|
||||
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete',
|
||||
'recipes.view', 'recipes.create', 'recipes.edit', 'recipes.delete',
|
||||
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
||||
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
||||
'users.view', 'users.create', 'users.edit',
|
||||
@@ -110,7 +152,12 @@ class PermissionSeeder extends Seeder
|
||||
// warehouse-manager 管理庫存與倉庫
|
||||
$warehouseManager->givePermissionTo([
|
||||
'products.view',
|
||||
'inventory.view', 'inventory.adjust', 'inventory.transfer',
|
||||
'inventory.view', 'inventory.delete',
|
||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
||||
'warehouses.view', 'warehouses.create', 'warehouses.edit',
|
||||
]);
|
||||
|
||||
@@ -120,6 +167,7 @@ class PermissionSeeder extends Seeder
|
||||
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||
'vendors.view', 'vendors.create', 'vendors.edit',
|
||||
'inventory.view',
|
||||
'goods_receipts.view', 'goods_receipts.create',
|
||||
]);
|
||||
|
||||
// viewer 僅能查看
|
||||
@@ -127,6 +175,7 @@ class PermissionSeeder extends Seeder
|
||||
'products.view',
|
||||
'purchase_orders.view',
|
||||
'inventory.view',
|
||||
'goods_receipts.view',
|
||||
'vendors.view',
|
||||
'warehouses.view',
|
||||
'utility_fees.view',
|
||||
|
||||
@@ -34,6 +34,12 @@ class TenantDatabaseSeeder extends Seeder
|
||||
// 呼叫權限 Seeder 設定權限與角色
|
||||
$this->call(PermissionSeeder::class);
|
||||
|
||||
// 初始化基本單位資料
|
||||
$this->call(UnitSeeder::class);
|
||||
|
||||
// 初始化預設商品分類
|
||||
$this->call(CategorySeeder::class);
|
||||
|
||||
// 確保 admin 擁有 super-admin 角色
|
||||
if (!$admin->hasRole('super-admin')) {
|
||||
$admin->assignRole('super-admin');
|
||||
|
||||
@@ -15,6 +15,11 @@ fi
|
||||
|
||||
chmod -R ugo+rw /.composer
|
||||
|
||||
# 確保 storage 軟連結存在
|
||||
if [ ! -L /var/www/html/public/storage ]; then
|
||||
php /var/www/html/artisan storage:link
|
||||
fi
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
||||
exec "$@"
|
||||
|
||||
@@ -15,6 +15,11 @@ fi
|
||||
|
||||
chmod -R ugo+rw /.composer
|
||||
|
||||
# 確保 storage 軟連結存在
|
||||
if [ ! -L /var/www/html/public/storage ]; then
|
||||
php /var/www/html/artisan storage:link
|
||||
fi
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
||||
exec "$@"
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# 開發框架規範說明書:ERP 系統 (star-erp)
|
||||
|
||||
## 1. 專案概述
|
||||
* **目標**: 打造一個強大且穩定的 ERP 後台管理系統。
|
||||
* **核心架構**: 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。
|
||||
* **工作流程**: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。
|
||||
|
||||
## 2. 技術棧 (Tech Stack)
|
||||
* **後端**: PHP 8.5 / Laravel 12
|
||||
* **前端橋樑**: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
|
||||
* **前端庫**: React (以 Functional Components 與 Hooks 為主)
|
||||
* **樣式處理**: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
|
||||
* **資料庫**: MySQL 8.0
|
||||
* **開發環境**: Laravel Sail (Docker / WSL2)
|
||||
* **未來擴充**: 針對高併發或跨平台模組,預留 Golang 微服務接口。
|
||||
|
||||
## 3. 目錄結構與慣例
|
||||
|
||||
### 3.1 後端 (Laravel - Modular Monolith)
|
||||
系統採用模組化架構,核心邏輯位於 `app/Modules/` 下:
|
||||
|
||||
* **Modules**: 位於 `app/Modules/{ModuleName}/`。
|
||||
* **Controllers**: `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`。
|
||||
* **Models**: `app/Modules/{ModuleName}/Models/`。
|
||||
* **Routes**: `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。
|
||||
* **Global Routes**: `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。
|
||||
|
||||
### 3.2 前端 (React)
|
||||
* **Pages (頁面)**: 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。
|
||||
* **Components (組件)**: 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
|
||||
* **Layouts (版面)**: 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。
|
||||
|
||||
## 4. 整合指南 (UI/UX 轉換至 Laravel)
|
||||
* **組件遷移**: 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。
|
||||
* **資料傳遞**: 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。
|
||||
* **狀態管理**: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
|
||||
|
||||
## 5. 開發標準 (Coding Standards)
|
||||
* **命名規範**:
|
||||
* Controllers: `PascalCaseController.php`
|
||||
* React Components: `PascalCase.jsx`
|
||||
* Routes: `kebab-case` (小寫橫線分隔)
|
||||
* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||
|
||||
## 6. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 7. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
||||
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
||||
* **執行 Composer**: `./vendor/bin/sail composer install`
|
||||
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||
@@ -1,71 +0,0 @@
|
||||
# Multi-tenancy 部署手冊
|
||||
|
||||
> 記錄本地開發完成後,上 Demo/Production 環境時需要手動執行的操作。
|
||||
> CI/CD 會自動執行的項目已排除。
|
||||
|
||||
---
|
||||
|
||||
## Step 1: 安裝 stancl/tenancy
|
||||
**CI/CD 會自動執行**:`composer install`
|
||||
**手動操作**:無
|
||||
|
||||
---
|
||||
|
||||
## Step 2: 設定 Central Domain + Tenant 識別
|
||||
**手動操作**:
|
||||
1. 修改 `.env`,加入:
|
||||
```bash
|
||||
# Demo 環境 (192.168.0.103)
|
||||
CENTRAL_DOMAINS=192.168.0.103,localhost
|
||||
|
||||
# Production 環境 (erp.koori.tw)
|
||||
CENTRAL_DOMAINS=erp.koori.tw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: 分離 Migrations
|
||||
**CI/CD 會自動執行**:`php artisan migrate --force`
|
||||
**手動操作**:無
|
||||
|
||||
> 注意:migrations 結構已調整如下:
|
||||
> - `database/migrations/` - Central tables (tenants, domains)
|
||||
> - `database/migrations/tenant/` - Tenant tables (所有業務表)
|
||||
|
||||
---
|
||||
|
||||
## Step 4: 遷移現有資料到 tenant_koori
|
||||
**首次部署手動操作**:
|
||||
1. 授予 MySQL sail 使用者 CREATE DATABASE 權限:
|
||||
```bash
|
||||
docker exec koori-erp-mysql mysql -uroot -p[PASSWORD] -e "GRANT ALL PRIVILEGES ON *.* TO 'sail'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
2. 建立第一個租戶 (小小冰室):
|
||||
```bash
|
||||
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
|
||||
use App\Models\Tenant;
|
||||
Tenant::create(['id' => 'koori', 'name' => '小小冰室']);
|
||||
"
|
||||
```
|
||||
|
||||
3. 為租戶綁定域名:
|
||||
```bash
|
||||
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
|
||||
use App\Models\Tenant;
|
||||
Tenant::find('koori')->domains()->create(['domain' => 'koori.your-domain.com']);
|
||||
"
|
||||
```
|
||||
|
||||
4. 執行資料遷移 (從 central DB 複製到 tenant DB):
|
||||
```bash
|
||||
docker exec -w /var/www/html koori-erp-laravel php artisan tenancy:migrate-data koori
|
||||
```
|
||||
|
||||
## Step 5: 建立房東後台
|
||||
**手動操作**:無
|
||||
|
||||
---
|
||||
|
||||
## 其他注意事項
|
||||
- 待補充...
|
||||
@@ -1,5 +1,12 @@
|
||||
# 正式環境 (Production) - 端口 80
|
||||
# 外部 SSL 終止後(如 Cloudflare/NPM)轉發至此端口
|
||||
|
||||
# 定義 map 以正確處理 X-Forwarded-Proto
|
||||
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name erp.koori.tw erp.mamaiclub.com;
|
||||
@@ -9,7 +16,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
121
package-lock.json
generated
121
package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"name": "star-erp",
|
||||
"dependencies": {
|
||||
"@inertiajs/react": "^2.3.4",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -17,7 +18,9 @@
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
@@ -75,7 +78,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -851,6 +853,37 @@
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-accordion": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
|
||||
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
@@ -1672,6 +1705,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
||||
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
@@ -1690,6 +1769,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
@@ -2539,7 +2647,6 @@
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -2550,7 +2657,6 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2561,7 +2667,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2669,7 +2774,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2882,8 +2986,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
@@ -3762,7 +3865,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3824,7 +3926,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -3837,7 +3938,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -4330,7 +4430,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/react": "^2.3.4",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -31,7 +32,9 @@
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
|
||||
@@ -86,6 +86,8 @@ const fieldLabels: Record<string, string> = {
|
||||
quantity: '數量',
|
||||
safety_stock: '安全庫存',
|
||||
location: '儲位',
|
||||
unit_cost: '單位成本',
|
||||
total_value: '總價值',
|
||||
// 庫存欄位
|
||||
batch_number: '批號',
|
||||
box_number: '箱號',
|
||||
@@ -114,10 +116,24 @@ const fieldLabels: Record<string, string> = {
|
||||
transaction_date: '費用日期',
|
||||
category: '費用類別',
|
||||
amount: '金額',
|
||||
// 進貨單欄位
|
||||
gr_number: '進貨單號',
|
||||
received_date: '入庫日期',
|
||||
type: '入庫類型',
|
||||
remarks: '備註',
|
||||
// 生產管理欄位
|
||||
production_number: '工單編號',
|
||||
production_date: '生產日期',
|
||||
actual_quantity: '實際產量',
|
||||
consumption_status: '物料消耗狀態',
|
||||
recipe_id: '生產配方',
|
||||
recipe_name: '配方名稱',
|
||||
yield_quantity: '預期產量',
|
||||
};
|
||||
|
||||
// 採購單狀態對照表
|
||||
// 狀態翻譯對照表
|
||||
const statusMap: Record<string, string> = {
|
||||
// 採購單狀態
|
||||
draft: '草稿',
|
||||
pending: '待審核',
|
||||
approved: '已核准',
|
||||
@@ -125,6 +141,10 @@ const statusMap: Record<string, string> = {
|
||||
received: '已收貨',
|
||||
cancelled: '已取消',
|
||||
completed: '已完成',
|
||||
// 生產工單狀態
|
||||
planned: '已計畫',
|
||||
in_progress: '生產中',
|
||||
// completed 已定義
|
||||
};
|
||||
|
||||
// 庫存品質狀態對照表
|
||||
@@ -320,9 +340,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key))
|
||||
.map((key) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="font-medium text-gray-700 w-[150px]">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-words max-w-[200px]">-</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-words max-w-[200px]">
|
||||
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-all min-w-[150px]">-</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap">
|
||||
{getFormattedValue(key, attributes[key])}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -378,11 +398,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
|
||||
return (
|
||||
<TableRow key={key} className={isChanged ? 'bg-amber-50/30 hover:bg-amber-50/50' : 'hover:bg-gray-50/50'}>
|
||||
<TableCell className="font-medium text-gray-700 w-[150px]">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-words max-w-[200px]">
|
||||
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-all min-w-[150px] whitespace-pre-wrap">
|
||||
{displayBefore}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-words max-w-[200px]">
|
||||
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap">
|
||||
{displayAfter}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -173,16 +173,18 @@ export default function LogTable({
|
||||
<TableCell>
|
||||
<span className="font-medium text-gray-900">{activity.causer}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getDescription(activity)}
|
||||
<TableCell className="min-w-[300px]">
|
||||
<div className="break-all">
|
||||
{getDescription(activity)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={getEventBadgeClass(activity.event)}>
|
||||
{getEventLabel(activity.event)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200">
|
||||
<TableCell className="max-w-[200px]">
|
||||
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200 break-all whitespace-normal text-left h-auto py-1">
|
||||
{activity.subject_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function ApplicationLogo(props: ImgHTMLAttributes<HTMLImageElemen
|
||||
<img
|
||||
{...props}
|
||||
src="/logo.png"
|
||||
alt="小小冰室 Logo"
|
||||
alt={`${branding?.short_name || 'Star'} Logo`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
101
resources/js/Components/Inventory/GoodsReceiptActions.tsx
Normal file
101
resources/js/Components/Inventory/GoodsReceiptActions.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Link, useForm } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
|
||||
export interface GoodsReceipt {
|
||||
id: number;
|
||||
code: string;
|
||||
warehouse_id: number;
|
||||
warehouse?: { name: string };
|
||||
vendor_id?: number;
|
||||
vendor?: { name: string };
|
||||
received_date: string;
|
||||
status: string;
|
||||
type?: string;
|
||||
items_sum_total_amount?: number;
|
||||
user?: { name: string };
|
||||
}
|
||||
|
||||
export default function GoodsReceiptActions({
|
||||
receipt,
|
||||
}: { receipt: GoodsReceipt }) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const { delete: destroy, processing } = useForm({});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
// @ts-ignore
|
||||
destroy(route('goods-receipts.destroy', receipt.id), {
|
||||
onSuccess: () => {
|
||||
toast.success("進貨單已成功刪除");
|
||||
setShowDeleteDialog(false);
|
||||
},
|
||||
onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href={route('goods-receipts.show', receipt.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="查看詳情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Delete typically restricted for Goods Receipts, checking permission */}
|
||||
<Can permission="goods_receipts.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除進貨單</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除進貨單 「{receipt.code}」 嗎?
|
||||
<br />
|
||||
<span className="text-red-500 font-bold mt-2 block">
|
||||
注意:刪除進貨單將會扣除已入庫的庫存數量!
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="button-filled-error"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
|
||||
|
||||
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
|
||||
processing: { label: "處理中", variant: "warning" },
|
||||
completed: { label: "已完成", variant: "success" },
|
||||
cancelled: { label: "已取消", variant: "destructive" },
|
||||
};
|
||||
|
||||
interface GoodsReceiptStatusBadgeProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GoodsReceiptStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: GoodsReceiptStatusBadgeProps) {
|
||||
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
|
||||
// Apply custom styling based on variant mapping if not using standard badge variants
|
||||
let badgeClass = "";
|
||||
switch (config.variant) {
|
||||
case "success":
|
||||
badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
|
||||
break;
|
||||
case "warning":
|
||||
badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
|
||||
break;
|
||||
case "destructive":
|
||||
badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
|
||||
break;
|
||||
default:
|
||||
badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
258
resources/js/Components/Inventory/GoodsReceiptTable.tsx
Normal file
258
resources/js/Components/Inventory/GoodsReceiptTable.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 進貨單列表表格
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import GoodsReceiptActions, { GoodsReceipt } from "./GoodsReceiptActions";
|
||||
import GoodsReceiptStatusBadge from "./GoodsReceiptStatusBadge";
|
||||
import CopyButton from "@/Components/shared/CopyButton";
|
||||
import { formatCurrency, formatDate } from "@/utils/format";
|
||||
|
||||
interface GoodsReceiptTableProps {
|
||||
receipts: GoodsReceipt[];
|
||||
}
|
||||
|
||||
type SortField = "code" | "type" | "warehouse_name" | "vendor_name" | "received_date" | "total_amount" | "status";
|
||||
type SortDirection = "asc" | "desc" | null;
|
||||
|
||||
export default function GoodsReceiptTable({
|
||||
receipts,
|
||||
}: GoodsReceiptTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
||||
|
||||
// 處理排序
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortDirection === "asc") {
|
||||
setSortDirection("desc");
|
||||
} else if (sortDirection === "desc") {
|
||||
setSortDirection(null);
|
||||
setSortField(null);
|
||||
} else {
|
||||
setSortDirection("asc");
|
||||
}
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 類型翻譯映射
|
||||
const typeMap: Record<string, string> = {
|
||||
standard: "標準採購",
|
||||
miscellaneous: "雜項入庫",
|
||||
other: "其他入庫",
|
||||
};
|
||||
|
||||
// 排序後的進貨單列表
|
||||
const sortedReceipts = useMemo(() => {
|
||||
if (!sortField || !sortDirection) {
|
||||
return receipts;
|
||||
}
|
||||
|
||||
return [...receipts].sort((a, b) => {
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
|
||||
switch (sortField) {
|
||||
case "code":
|
||||
aValue = a.code;
|
||||
bValue = b.code;
|
||||
break;
|
||||
case "type":
|
||||
aValue = typeMap[a.status] || a.status; // status here might actually refer to type in existing code logic? Let's use a.type if it exists.
|
||||
// Checking if 'type' is in receipt - based on implementation plan we want it.
|
||||
// Currently GoodsReceipt model HAS type.
|
||||
// @ts-ignore
|
||||
aValue = typeMap[a.type] || a.type || "";
|
||||
// @ts-ignore
|
||||
bValue = typeMap[b.type] || b.type || "";
|
||||
break;
|
||||
case "warehouse_name":
|
||||
aValue = a.warehouse?.name || "";
|
||||
bValue = b.warehouse?.name || "";
|
||||
break;
|
||||
case "vendor_name":
|
||||
aValue = a.vendor?.name || "";
|
||||
bValue = b.vendor?.name || "";
|
||||
break;
|
||||
case "received_date":
|
||||
aValue = a.received_date;
|
||||
bValue = b.received_date;
|
||||
break;
|
||||
case "total_amount":
|
||||
aValue = a.items_sum_total_amount || 0;
|
||||
bValue = b.items_sum_total_amount || 0;
|
||||
break;
|
||||
case "status":
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return sortDirection === "asc"
|
||||
? aValue.localeCompare(bValue, "zh-TW")
|
||||
: bValue.localeCompare(aValue, "zh-TW");
|
||||
} else {
|
||||
return sortDirection === "asc"
|
||||
? (aValue as number) - (bValue as number)
|
||||
: (bValue as number) - (aValue as number);
|
||||
}
|
||||
});
|
||||
}, [receipts, sortField, sortDirection]);
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) {
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
if (sortDirection === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
if (sortDirection === "desc") {
|
||||
return <ArrowDown className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden mt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("code")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
進貨單編號
|
||||
<SortIcon field="code" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
<button
|
||||
onClick={() => handleSort("type")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
入庫類型
|
||||
<SortIcon field="type" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("warehouse_name")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
入庫倉庫
|
||||
<SortIcon field="warehouse_name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("vendor_name")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
供應商
|
||||
<SortIcon field="vendor_name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">
|
||||
<button
|
||||
onClick={() => handleSort("received_date")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
進貨日期
|
||||
<SortIcon field="received_date" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-right">
|
||||
<button
|
||||
onClick={() => handleSort("total_amount")}
|
||||
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors"
|
||||
>
|
||||
總金額
|
||||
<SortIcon field="total_amount" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px] text-center">
|
||||
<button
|
||||
onClick={() => handleSort("status")}
|
||||
className="flex items-center gap-2 mx-auto hover:text-foreground transition-colors"
|
||||
>
|
||||
狀態
|
||||
<SortIcon field="status" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedReceipts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-12">
|
||||
尚無進貨單
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedReceipts.map((receipt, index) => (
|
||||
<TableRow key={receipt.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-sm font-medium">{receipt.code}</span>
|
||||
<CopyButton text={receipt.code} label="複製單號" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{/* @ts-ignore */}
|
||||
{typeMap[receipt.type] || receipt.type || "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{receipt.warehouse?.name || "-"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-700">{receipt.vendor?.name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-500">{formatDate(receipt.received_date)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{formatCurrency(receipt.items_sum_total_amount)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<GoodsReceiptStatusBadge status={receipt.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<GoodsReceiptActions receipt={receipt} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -36,6 +37,7 @@ export default function ProductDialog({
|
||||
}: ProductDialogProps) {
|
||||
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
||||
code: "",
|
||||
barcode: "",
|
||||
name: "",
|
||||
category_id: "",
|
||||
brand: "",
|
||||
@@ -44,6 +46,7 @@ export default function ProductDialog({
|
||||
large_unit_id: "",
|
||||
conversion_rate: "",
|
||||
purchase_unit_id: "",
|
||||
location: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,6 +55,7 @@ export default function ProductDialog({
|
||||
if (product) {
|
||||
setData({
|
||||
code: product.code,
|
||||
barcode: product.barcode || "",
|
||||
name: product.name,
|
||||
category_id: product.categoryId.toString(),
|
||||
brand: product.brand || "",
|
||||
@@ -60,6 +64,7 @@ export default function ProductDialog({
|
||||
large_unit_id: product.largeUnitId?.toString() || "",
|
||||
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
|
||||
purchase_unit_id: product.purchaseUnitId?.toString() || "",
|
||||
location: product.location || "",
|
||||
});
|
||||
} else {
|
||||
reset();
|
||||
@@ -77,7 +82,6 @@ export default function ProductDialog({
|
||||
if (product) {
|
||||
put(route("products.update", product.id), {
|
||||
onSuccess: () => {
|
||||
toast.success("商品已更新");
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
@@ -88,7 +92,6 @@ export default function ProductDialog({
|
||||
} else {
|
||||
post(route("products.store"), {
|
||||
onSuccess: () => {
|
||||
toast.success("商品已新增");
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
@@ -99,6 +102,11 @@ export default function ProductDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const generateRandomBarcode = () => {
|
||||
const randomDigits = Math.floor(Math.random() * 9000000000) + 1000000000;
|
||||
setData("barcode", randomDigits.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
@@ -152,13 +160,46 @@ export default function ProductDialog({
|
||||
id="code"
|
||||
value={data.code}
|
||||
onChange={(e) => setData("code", e.target.value)}
|
||||
placeholder="例:A1 (最多2碼)"
|
||||
maxLength={2}
|
||||
placeholder="例:A1 (2-8碼)"
|
||||
maxLength={8}
|
||||
className={errors.code ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.code && <p className="text-sm text-red-500">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="barcode">
|
||||
條碼編號 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="barcode"
|
||||
value={data.barcode}
|
||||
onChange={(e) => setData("barcode", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// 掃描後自動跳轉到下一個欄位(品牌)
|
||||
document.getElementById('brand')?.focus();
|
||||
}
|
||||
}}
|
||||
placeholder="輸入條碼或自動生成"
|
||||
className={`flex-1 ${errors.barcode ? "border-red-500" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={generateRandomBarcode}
|
||||
title="隨機生成條碼"
|
||||
className="shrink-0 button-outlined-primary"
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{errors.barcode && <p className="text-sm text-red-500">{errors.barcode}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand">品牌</Label>
|
||||
<Input
|
||||
@@ -169,6 +210,16 @@ export default function ProductDialog({
|
||||
/>
|
||||
{errors.brand && <p className="text-sm text-red-500">{errors.brand}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">儲位</Label>
|
||||
<Input
|
||||
id="location"
|
||||
value={data.location}
|
||||
onChange={(e) => setData("location", e.target.value)}
|
||||
placeholder="例:A-1-1"
|
||||
/>
|
||||
{errors.location && <p className="text-sm text-red-500">{errors.location}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="specification">規格描述</Label>
|
||||
|
||||
142
resources/js/Components/Product/ProductImportDialog.tsx
Normal file
142
resources/js/Components/Product/ProductImportDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Upload, Download, FileSpreadsheet, AlertCircle, Info } from "lucide-react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/Components/ui/accordion";
|
||||
import { useForm } from "@inertiajs/react";
|
||||
import { Alert, AlertDescription } from "@/Components/ui/alert";
|
||||
|
||||
interface ProductImportDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ProductImportDialog({ open, onOpenChange }: ProductImportDialogProps) {
|
||||
const { data, setData, post, processing, errors, reset, clearErrors } = useForm<{
|
||||
file: File | null;
|
||||
}>({
|
||||
file: null,
|
||||
});
|
||||
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setData("file", e.target.files[0]);
|
||||
clearErrors("file");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route("products.import"), {
|
||||
onSuccess: () => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
window.location.href = route('products.template');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>匯入商品資料</DialogTitle>
|
||||
<DialogDescription>
|
||||
請先下載範本,填寫後上傳。系統將自動建立商品資料。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 步驟 1: 下載範本 */}
|
||||
<div className="space-y-2 p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<Label className="font-medium flex items-center gap-2">
|
||||
<FileSpreadsheet className="w-4 h-4 text-green-600" />
|
||||
步驟 1:取得 Excel 範本
|
||||
</Label>
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
下載標準範本以確保資料格式正確。請勿修改欄位名稱。
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadTemplate}
|
||||
className="w-full sm:w-auto button-outlined-primary"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
下載範本 (.xlsx)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 步驟 2: 上傳檔案 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-blue-600" />
|
||||
步驟 2:上傳填寫後的檔案
|
||||
</Label>
|
||||
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".xlsx, .xls"
|
||||
onChange={handleFileChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{errors.file && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="whitespace-pre-wrap">
|
||||
{errors.file}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 欄位說明 */}
|
||||
<Accordion type="single" collapsible className="w-full border rounded-lg px-2">
|
||||
<AccordionItem value="item-1" className="border-b-0">
|
||||
<AccordionTrigger className="text-sm text-gray-500 hover:no-underline py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
欄位填寫規則
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
|
||||
<ul className="list-disc space-y-1">
|
||||
<li><span className="font-medium text-gray-700">必填欄位</span>:商品代號 (2-8 碼)、條碼、商品名稱、類別名稱、基本單位。</li>
|
||||
<li><span className="font-medium text-gray-700">唯一性</span>:商品代號與條碼不可與現有資料重複。</li>
|
||||
<li><span className="font-medium text-gray-700">自動關聯</span>:類別與單位請填寫系統當前存在的「名稱」(如:飲品、瓶)。</li>
|
||||
<li><span className="font-medium text-gray-700">大單位</span>:若填寫大單位,則「換算率」為必填(需大於 0)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={processing}
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={!data.file || processing} className="button-filled-primary">
|
||||
{processing ? "匯入中..." : "開始匯入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/Components/ui/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -74,11 +80,7 @@ export default function ProductTable({
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>
|
||||
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
|
||||
商品代號 <SortIcon field="code" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">條碼編號</TableHead>
|
||||
<TableHead>
|
||||
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
|
||||
商品名稱 <SortIcon field="name" />
|
||||
@@ -94,7 +96,9 @@ export default function ProductTable({
|
||||
基本單位 <SortIcon field="base_unit_id" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px]">規格</TableHead>
|
||||
<TableHead>換算率</TableHead>
|
||||
<TableHead>儲位</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -112,12 +116,15 @@ export default function ProductTable({
|
||||
{startIndex + index}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm text-gray-700">
|
||||
{product.code}
|
||||
{product.barcode || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{product.name}</span>
|
||||
{product.brand && <span className="text-xs text-gray-400">{product.brand}</span>}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-grey-0">{product.name}</span>
|
||||
{product.brand && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-gray-100 text-gray-500 border-none">{product.brand}</Badge>}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 font-mono">代號: {product.code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -126,6 +133,22 @@ export default function ProductTable({
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="truncate text-gray-600 cursor-help">
|
||||
{product.specification || '-'}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{product.specification && (
|
||||
<TooltipContent className="max-w-xs break-all">
|
||||
<p className="whitespace-pre-wrap">{product.specification}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{product.largeUnit ? (
|
||||
<span className="text-sm text-gray-500">
|
||||
@@ -135,6 +158,9 @@ export default function ProductTable({
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-600">{product.location || '-'}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{/*
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import type { PurchaseOrderItem, Supplier } from "@/types/purchase-order";
|
||||
import { formatCurrency } from "@/utils/purchase-order";
|
||||
|
||||
@@ -204,14 +215,35 @@ export function PurchaseOrderItemsTable({
|
||||
{/* 刪除按鈕 */}
|
||||
{!isReadOnly && onRemoveItem && (
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="h-8 w-8 text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="移除項目"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要移除此商品嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此動作將從清單中移除該商品,您之後需要重新搜尋才能再次加入。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
確定移除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { PurchaseOrderStatus } from "@/types/purchase-order";
|
||||
import { STATUS_CONFIG } from "@/constants/purchase-order";
|
||||
|
||||
interface PurchaseOrderStatusBadgeProps {
|
||||
status: PurchaseOrderStatus;
|
||||
@@ -14,33 +15,12 @@ export default function PurchaseOrderStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: PurchaseOrderStatusBadgeProps) {
|
||||
const getStatusConfig = (status: PurchaseOrderStatus) => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return { label: "草稿", className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
case "pending":
|
||||
return { label: "待審核", className: "bg-blue-100 text-blue-700 border-blue-200" };
|
||||
case "processing":
|
||||
return { label: "處理中", className: "bg-yellow-100 text-yellow-700 border-yellow-200" };
|
||||
case "shipping":
|
||||
return { label: "運送中", className: "bg-purple-100 text-purple-700 border-purple-200" };
|
||||
case "confirming":
|
||||
return { label: "待確認", className: "bg-orange-100 text-orange-700 border-orange-200" };
|
||||
case "completed":
|
||||
return { label: "已完成", className: "bg-green-100 text-green-700 border-green-200" };
|
||||
case "cancelled":
|
||||
return { label: "已取消", className: "bg-red-100 text-red-700 border-red-200" };
|
||||
default:
|
||||
return { label: "未知", className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${config.className} ${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
variant={config.variant}
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
|
||||
@@ -10,13 +10,13 @@ interface StatusProgressBarProps {
|
||||
}
|
||||
|
||||
// 流程步驟定義
|
||||
const FLOW_STEPS: { key: PurchaseOrderStatus | "approved"; label: string }[] = [
|
||||
const FLOW_STEPS: { key: PurchaseOrderStatus; label: string }[] = [
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "pending", label: "待審核" },
|
||||
{ key: "processing", label: "處理中" },
|
||||
{ key: "shipping", label: "運送中" },
|
||||
{ key: "confirming", label: "待確認" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "pending", label: "簽核中" },
|
||||
{ key: "approved", label: "已核准" },
|
||||
{ key: "partial", label: "部分收貨" },
|
||||
{ key: "completed", label: "全數收貨" },
|
||||
{ key: "closed", label: "已結案" },
|
||||
];
|
||||
|
||||
export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
|
||||
@@ -82,7 +82,7 @@ export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isRejectedAtThisStep ? "已取消" : step.label}
|
||||
{isRejectedAtThisStep ? "已作廢" : step.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,6 @@ export default function EditSafetyStockDialog({
|
||||
};
|
||||
|
||||
onSave(updatedSetting);
|
||||
toast.success("安全庫存設定已更新");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ export default function UtilityFeeDialog({
|
||||
|
||||
put(route("utility-fees.update", fee.id), {
|
||||
onSuccess: () => {
|
||||
toast.success("紀錄已更新");
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
@@ -110,7 +109,6 @@ export default function UtilityFeeDialog({
|
||||
|
||||
post(route("utility-fees.store"), {
|
||||
onSuccess: () => {
|
||||
toast.success("公共事業費已記錄");
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
|
||||
@@ -67,7 +67,6 @@ export default function VendorDialog({
|
||||
if (vendor) {
|
||||
put(route("vendors.update", vendor.id), {
|
||||
onSuccess: () => {
|
||||
toast.success("廠商資料已更新");
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
@@ -78,7 +77,6 @@ export default function VendorDialog({
|
||||
} else {
|
||||
post(route("vendors.store"), {
|
||||
onSuccess: () => {
|
||||
toast.success("廠商已新增");
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user