Compare commits

...

15 Commits

Author SHA1 Message Date
036f4a4fb6 優化採購單與進貨單操作紀錄:新增品項明細、ID 轉名稱解析、前端多數量 key 通用顯示
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
ERP-Deploy-Production / deploy-production (push) Successful in 1m12s
- 重構 PurchaseOrder@tapActivity:支援 vendor_id/warehouse_id/user_id 自動解析為名稱
- 修改 PurchaseOrderController@store:改用 saveQuietly + 手動日誌,建立時紀錄品項明細
- 修正 PurchaseOrderController update/destroy snapshot 跨模組取值為 null 的問題
- 修改 GoodsReceiptService@store:改用 saveQuietly + 手動日誌,建立時紀錄品項明細
- 修改 ActivityDetailDialog.tsx:支援 quantity/quantity_received/requested_qty 多 key 通用渲染
- 新增項目顯示金額與備註,更新項目增加金額與備註變更對比
2026-03-02 17:30:55 +08:00
0a955fb993 feat: 整合門市領料日誌、API 文件存取、修改庫存與併發編號問題、供應商商品內聯編輯及日誌 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s
2026-03-02 16:42:12 +08:00
7dac2d1f77 實作產品與庫存匯入邏輯 (ProductImport, InventoryImport) 並更新相關 Service 與 Controller
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s
2026-03-02 11:58:04 +08:00
649af40919 實作 InventoryService 的批量入庫 (processIncomingInventory) 與庫存調整 (adjustInventory) 邏輯
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-02 10:47:43 +08:00
5f8b2a1c2d 新增 POS 庫存查詢 API:實作 InventorySyncController 與相關 Service 邏輯,並更新 API 整合手冊
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m24s
2026-03-02 10:19:38 +08:00
4bbbde685d feat: 更新系統操作手冊內容並新增本地代理配置
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-02-26 13:54:35 +08:00
5e32526471 style(Frontend): 將側邊欄與麵包屑導覽的『報表管理』更名為『報表與分析』
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m16s
2026-02-26 10:44:43 +08:00
f960aaaeb2 feat(Inventory): 實作批號溯源完整功能與 UI 呈現,包含文字敘述卡片與更完整的關聯屬性 2026-02-26 10:39:24 +08:00
63e4f88a14 優化門市叫貨流程:實作庫存預扣機制、鎖定自動產生的調撥單明細、修復自動販賣機貨道數量連動 Bug 及狀態同步問題
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s
2026-02-25 17:32:28 +08:00
e3df090afd feat: 統一各模組分頁組件佈局並新增系統設定功能相關檔案
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m5s
2026-02-25 16:16:49 +08:00
878b90e2ad UI: 統一各單據詳情頁面標題與基本資訊排版 2026-02-25 14:56:15 +08:00
299cf37054 fix: 修正全系統側邊欄捲軸重置問題
在所有報表與管理頁面的 router.get 調用中加入 preserveScroll: true。
受影響模組包括:
- 財務管理 (會計報表、公用事業費)
- 庫存管理 (庫存查詢、倉庫管理、進貨、調整、調撥)
- 生產管理 (工單管理、配方管理)
- 採購管理 (採購單)
- 銷售與發貨管理 (銷售單、發貨單、匯入管理)
- 系統管理 (使用者、角色、操作紀錄)
2026-02-25 14:04:22 +08:00
5668e17e61 style: 暫時隱藏採購退回單與出貨單側邊欄項目
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 52s
2026-02-25 13:51:41 +08:00
c4908533a8 feat(procurement): 實作採購退回單模組並修復商品選單報錯
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 58s
2026-02-25 13:49:02 +08:00
deef3baacc refactor: 重構模組通訊與調整儀表板功能
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
- 依循跨模組通訊規範,將 Sales 與 Production 模組中對 Inventory 的直接模型關聯改為透過 InventoryServiceInterface 取得
- 於 InventoryService 實作獲取最高庫存價值、即將過期商品等方法,供儀表板使用
- 確保所有跨模組調用皆採用手動水和(Manual Hydration)方式組合資料
- 移除本地已歸檔的 .agent 規範檔案
2026-02-25 11:48:52 +08:00
165 changed files with 8376 additions and 3592 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
---
trigger: always_on
---
---
name: 操作紀錄實作規範
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。

View File

@@ -1,3 +1,7 @@
---
trigger: always_on
---
---
name: 跨模組調用與通訊規範 (Cross-Module Communication)
description: 規範 Laravel Modular Monolith 架構下不同業務模組中如何彼此調用資料與邏輯包含禁止項目、Interface 實作、與 Service 綁定規則。
@@ -9,7 +13,7 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
## 🚫 絕對禁止的行為 (Strict Prohibitions)
* **禁止跨模組 Eloquent 關聯**
* **禁止跨模組 Eloquent 關聯(例外除外)**
* **錯誤**:在 `app/Modules/Sales/Models/Order.php` 中撰寫 `public function product() { return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); }`
* **原因**:這會造成資料庫查詢的強耦合。如果 `Inventory` 模組修改了 Schema`Sales` 模組會無預警崩壞。
* **禁止跨模組直接引入 (use) Model**
@@ -19,6 +23,19 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
---
## 🌟 允許的全域例外 (Global Exceptions)
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model
1. **`App\Modules\Core\Models\User`**:因為幾乎所有表都有 `created_by` / `updated_by`,直接關聯可保留 `with('creator')` 等便利性。
2. **`App\Modules\Core\Models\Role`**:權限判定已深度整合至系統底層。
3. **`App\Modules\Core\Models\Tenant`**:多租戶架構 (Tenancy) 的核心基石,底層查詢會頻繁依賴。
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`**絕對不能**反過來直接 `use` 外部業務模組的 Model仍必須透過外部模組的 Service Interface 來索取資料。
---
## ✅ 正確的跨模組調用流程:合約與依賴反轉
所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。

View File

@@ -2,10 +2,6 @@
trigger: always_on
---
---
trigger: always_on
---
# 開發框架規範說明書ERP 系統 (star-erp)
## 1. 專案概述

View File

@@ -1,3 +1,7 @@
---
trigger: always_on
---
---
name: 權限管理與實作規範
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。

View File

@@ -0,0 +1,99 @@
---
trigger: always_on
---
---
name: 客戶端後台 UI 統一規範
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
---
## 適用範圍
本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
## 核心禁止事項
- ❌ **禁止 Hardcode 色碼**(如 `text-[#01ab83]`),必須使用 `*-primary-main` 等 Tailwind Class 或 CSS 變數
- ❌ **禁止使用非 `lucide-react` 的圖標庫**(如 FontAwesome、Material Icons
- ❌ **禁止操作按鈕不包裹 `<Can>` 權限元件**
---
## 1. 色彩系統
### 主題色(動態租戶品牌色,由 `AuthenticatedLayout` 自動注入)
| Tailwind Class | 用途 |
|---|---|
| `*-primary-main` | 主色:按鈕、連結、強調 |
| `*-primary-dark` | Hover 狀態 |
| `*-primary-light` | 次要強調 |
| `*-primary-lightest` | 背景底色、Active 狀態 |
### 灰階與狀態色
直接參考 `resources/css/app.css` 中定義的 `--grey-0` ~ `--grey-5``--other-success/error/warning/info` 變數。
---
## 2. 按鈕規範
樣式定義於 `resources/css/app.css`,按鈕必須使用以下類別:
| 類型 | 類別 | 用途 |
|---|---|---|
| Filled | `button-filled-primary` | 主要操作(新增、儲存) |
| Filled | `button-filled-success/info/warning/error` | 各狀態操作 |
| Outlined | `button-outlined-primary` | 次要操作(編輯、檢視) |
| Outlined | `button-outlined-error` | 刪除按鈕 |
| Text | `button-text-primary` | 文字連結式按鈕 |
**尺寸**:表格操作列用 `size="sm"`,一般操作用 `size="default"`,主要 CTA 用 `size="lg"`
**返回按鈕**:放置於標題上方,使用 `variant="outline"` + `className="gap-2 button-outlined-primary"`,搭配 `<ArrowLeft />` 圖標。
---
## 3. 圖標規範
統一使用 `lucide-react`
| 尺寸 | 用途 |
|---|---|
| `h-4 w-4` | 按鈕內、表格操作 |
| `h-5 w-5` | 側邊欄選單 |
| `h-6 w-6` | 頁面標題 |
常用映射:`Plus`(新增)、`Pencil`(編輯)、`Trash2`(刪除)、`Eye`(檢視)、`Search`(搜尋)、`ArrowLeft`(返回)。
其餘請參考 `AuthenticatedLayout.tsx` 中的 `allMenuItems` 定義。
---
## 4. 頁面佈局規範
所有頁面遵循以下結構,參考範例:`Pages/Product/Create.tsx``Pages/PurchaseOrder/Create.tsx`
**關鍵規則**
- **外層容器**`className="container mx-auto p-6 max-w-7xl"`
- **標題樣式**`text-2xl font-bold text-grey-0 flex items-center gap-2`
- **說明文字**`text-gray-500 mt-1`
- **麵包屑**:使用 `BreadcrumbItemType`(屬性為 `label`, `href`, `isPage`),不需要包含「首頁」
- **Input 元件**:不額外設定 focus 樣式,直接使用 `@/Components/ui/input` 的內建樣式
- **日期顯示**:使用 `resources/js/lib/date.ts``formatDate` 工具
---
## 5. 表格規範
**容器**`bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden`
**標題列**`bg-gray-50`,序號欄 `w-[50px] text-center`,操作欄置中
**空狀態**`text-center py-8 text-gray-500`,顯示「無符合條件的資料」
**操作欄**`flex items-center justify-center gap-2`
### 排序(三態切換)
- 未排序:`ArrowUpDown``text-muted-foreground`
- 升冪:`ArrowUp``text-primary`
- 降冪:`ArrowDown``text-primary`
- 後端必須處理 `sort_by``sort_order` 參數
- 參考實作:`Pages/Product/Index.tsx``handleSort`

View File

@@ -22,6 +22,7 @@ class ActivityLogController extends Controller
'App\Modules\Procurement\Models\PurchaseOrder' => '採購單',
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
'App\Modules\Inventory\Models\Inventory' => '庫存',
'App\Modules\Inventory\Models\InventoryTransaction' => '庫存異動紀錄',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
@@ -31,12 +32,17 @@ class ActivityLogController extends Controller
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
];
}
public function index(Request $request)
{
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$sortBy = $request->input('sort_by', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');

View File

@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Sales\Contracts\SalesServiceInterface;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use Inertia\Inertia;
use Illuminate\Http\Request;
@@ -13,13 +15,19 @@ class DashboardController extends Controller
{
protected $inventoryService;
protected $procurementService;
protected $salesService;
protected $productionService;
public function __construct(
InventoryServiceInterface $inventoryService,
ProcurementServiceInterface $procurementService
ProcurementServiceInterface $procurementService,
SalesServiceInterface $salesService,
ProductionServiceInterface $productionService
) {
$this->inventoryService = $inventoryService;
$this->procurementService = $procurementService;
$this->salesService = $salesService;
$this->productionService = $productionService;
}
public function index()
@@ -35,99 +43,70 @@ class DashboardController extends Controller
$procStats = $this->procurementService->getDashboardStats();
// 銷售統計 (本月營收)
$thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->sum('amount');
$thisMonthRevenue = $this->salesService->getThisMonthRevenue();
// 生產統計 (待核准工單)
$pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count();
$pendingProductionCount = $this->productionService->getPendingProductionCount();
// 生產狀態分佈
// 近30日銷售趨勢 (Area Chart)
$startDate = now()->subDays(29)->startOfDay();
$salesData = \App\Modules\Sales\Models\SalesImportItem::where('transaction_at', '>=', $startDate)
->selectRaw('DATE(transaction_at) as date, SUM(amount) as total')
->groupBy('date')
->orderBy('date')
->get()
->mapWithKeys(function ($item) {
return [$item->date => (int)$item->total];
});
$salesTrend = [];
for ($i = 0; $i < 30; $i++) {
$date = $startDate->copy()->addDays($i)->format('Y-m-d');
$salesTrend[] = [
'date' => $startDate->copy()->addDays($i)->format('m/d'),
'amount' => $salesData[$date] ?? 0,
];
}
$salesTrend = $this->salesService->getSalesTrend();
// 本月熱銷商品 Top 5 (Bar Chart)
$topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product')
->whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_amount')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : $item->product_code,
'amount' => (int)$item->total_amount,
];
});
$topSellingItems = $this->salesService->getTopSellingProducts();
$productIds = $topSellingItems->pluck('product_id')->filter()->unique()->toArray();
$productsMap = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$topSellingProducts = $topSellingItems->map(function ($item) use ($productsMap) {
$product = $productsMap->get($item->product_id);
return [
'name' => $product ? $product->name : $item->product_code,
'amount' => (int)$item->total_amount,
];
});
// 庫存積壓排行 (Top Inventory Value)
$topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product')
->select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value'))
->where('quantity', '>', 0)
->groupBy('product_id')
->orderByDesc('total_value')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : 'Unknown Product',
'code' => $item->product ? $item->product->code : '',
'value' => (int)$item->total_value,
];
});
$topInventoryValueItems = $this->inventoryService->getTopInventoryValue();
$invProductIds = $topInventoryValueItems->pluck('product_id')->filter()->unique()->toArray();
$invProductsMap = $this->inventoryService->getProductsByIds($invProductIds)->keyBy('id');
$topInventoryValue = $topInventoryValueItems->map(function ($item) use ($invProductsMap) {
$product = $invProductsMap->get($item->product_id);
return [
'name' => $product ? $product->name : 'Unknown Product',
'code' => $product ? $product->code : '',
'value' => (int)$item->total_value,
];
});
// 熱銷數量排行 (Top Selling by Quantity)
$topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product')
->whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_quantity')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : $item->product_code,
'code' => $item->product_code,
'value' => (int)$item->total_quantity,
];
});
$topSellingQtyItems = $this->salesService->getTopSellingByQuantity();
$qtyProductIds = $topSellingQtyItems->pluck('product_id')->filter()->unique()->toArray();
$qtyProductsMap = $this->inventoryService->getProductsByIds($qtyProductIds)->keyBy('id');
$topSellingByQuantity = $topSellingQtyItems->map(function ($item) use ($qtyProductsMap) {
$product = $qtyProductsMap->get($item->product_id);
return [
'name' => $product ? $product->name : $item->product_code,
'code' => $item->product_code,
'value' => (int)$item->total_quantity,
];
});
// 即將過期商品 (Expiring Soon)
$expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product')
->where('quantity', '>', 0)
->whereNotNull('expiry_date')
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
->orderBy('expiry_date', 'asc')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : 'Unknown Product',
'batch_number' => $item->batch_number,
'expiry_date' => $item->expiry_date->format('Y-m-d'),
'quantity' => (int)$item->quantity,
];
});
$expiringItems = $this->inventoryService->getExpiringSoon();
$expiringProductIds = $expiringItems->pluck('product_id')->filter()->unique()->toArray();
$expiringProductsMap = $this->inventoryService->getProductsByIds($expiringProductIds)->keyBy('id');
$expiringSoon = $expiringItems->map(function ($item) use ($expiringProductsMap) {
$product = $expiringProductsMap->get($item->product_id);
return [
'name' => $product ? $product->name : 'Unknown Product',
'batch_number' => $item->batch_number,
'expiry_date' => $item->expiry_date->format('Y-m-d'),
'quantity' => (int)$item->quantity,
];
});
return Inertia::render('Dashboard', [
'stats' => [

View File

@@ -185,8 +185,10 @@ class RoleController extends Controller
'inventory_adjust' => '庫存盤調管理',
'inventory_transfer' => '庫存調撥管理',
'inventory_report' => '庫存報表',
'inventory_traceability' => '批號溯源',
'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理',
'purchase_returns' => '採購退回管理',
'goods_receipts' => '進貨單管理',
'delivery_notes' => '出貨單管理',
'recipes' => '配方管理',

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Core\Models\SystemSetting;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SystemSettingController extends Controller
{
/**
* 顯示系統設定頁面
*/
public function index()
{
$settings = SystemSetting::all()->groupBy('group');
return Inertia::render('Admin/Setting/Index', [
'settings' => $settings,
]);
}
/**
* 更新系統設定
*/
public function update(Request $request)
{
$validated = $request->validate([
'settings' => 'required|array',
'settings.*.key' => 'required|string|exists:system_settings,key',
'settings.*.value' => 'nullable',
]);
foreach ($validated['settings'] as $item) {
SystemSetting::where('key', $item['key'])->update([
'value' => $item['value']
]);
}
// 清除記憶體快取,確保後續讀取拿到最新值
SystemSetting::clearCache();
return redirect()->back()->with('success', '系統設定已更新');
}
}

View File

@@ -18,7 +18,12 @@ class UserController extends Controller
*/
public function index(Request $request)
{
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$sortBy = $request->input('sort_by', 'id');
$sortOrder = $request->input('sort_order', 'asc');
$search = $request->input('search');

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Modules\Core\Models;
use Illuminate\Database\Eloquent\Model;
class SystemSetting extends Model
{
protected $fillable = [
'group',
'key',
'value',
'type',
'description',
];
/**
* 同請求內的記憶體快取,避免重複查詢 DB
* PHP 請求結束後自動釋放,無需額外處理失效
*/
protected static array $cache = [];
/**
* 取得特定設定值(含記憶體快取)
*/
public static function getVal(string $key, $default = null)
{
if (array_key_exists($key, static::$cache)) {
return static::$cache[$key];
}
$setting = self::where('key', $key)->first();
if (!$setting) {
static::$cache[$key] = $default;
return $default;
}
$value = $setting->value;
// 根據 type 進行類別轉換
$resolved = match ($setting->type) {
'integer', 'number' => (int) $value,
'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
'json', 'array' => json_decode($value, true),
default => $value,
};
static::$cache[$key] = $resolved;
return $resolved;
}
/**
* 清除記憶體快取(儲存設定後應呼叫)
*/
public static function clearCache(): void
{
static::$cache = [];
}
}

View File

@@ -7,6 +7,7 @@ use App\Modules\Core\Controllers\ProfileController;
use App\Modules\Core\Controllers\RoleController;
use App\Modules\Core\Controllers\UserController;
use App\Modules\Core\Controllers\ActivityLogController;
use App\Modules\Core\Controllers\SystemSettingController;
// 登入/登出路由
Route::get('/login', [LoginController::class, 'show'])->name('login');
@@ -56,5 +57,10 @@ Route::middleware('auth')->group(function () {
Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
});
Route::middleware('permission:system.settings.view')->group(function () {
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update');
});
});
});

View File

@@ -7,23 +7,41 @@ use App\Modules\Finance\Models\AccountPayable;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
class AccountPayableController extends Controller
{
protected $procurementService;
protected $inventoryService;
public function __construct(
ProcurementServiceInterface $procurementService,
InventoryServiceInterface $inventoryService
) {
$this->procurementService = $procurementService;
$this->inventoryService = $inventoryService;
}
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$query = AccountPayable::with(['vendor', 'creator']);
$query = AccountPayable::with(['creator']);
// 關鍵字搜尋 (單號、供應商名稱)
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('document_number', 'like', "%{$search}%")
->orWhereHas('vendor', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
// 透過 ProcurementService 查詢符合關鍵字的 Vendor IDs
$matchedVendors = $this->procurementService->searchVendors($search);
$vendorIds = $matchedVendors->pluck('id')->toArray();
$query->where(function ($q) use ($search, $vendorIds) {
$q->where('document_number', 'like', "%{$search}%");
if (!empty($vendorIds)) {
$q->orWhereIn('vendor_id', $vendorIds);
}
});
}
@@ -45,10 +63,23 @@ class AccountPayableController extends Controller
$query->where('due_date', '<=', $request->date_end);
}
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$payables = $query->latest()->paginate($perPage)->withQueryString();
$vendors = \App\Modules\Procurement\Models\Vendor::select('id', 'name')->get();
// Manual Hydration for Vendors
$allVendorIds = collect($payables->items())->pluck('vendor_id')->unique()->filter()->toArray();
$vendorsMap = $this->procurementService->getVendorsByIds($allVendorIds)->keyBy('id');
$payables->getCollection()->transform(function ($item) use ($vendorsMap) {
$item->vendor = $vendorsMap->get($item->vendor_id);
return $item;
});
$vendors = $this->procurementService->getAllVendors();
return Inertia::render('AccountPayable/Index', [
'payables' => $payables,
@@ -62,14 +93,19 @@ class AccountPayableController extends Controller
*/
public function show(AccountPayable $accountPayable)
{
$accountPayable->load(['vendor', 'creator']);
$accountPayable->load(['creator']);
if ($accountPayable->vendor_id) {
$accountPayable->vendor = $this->procurementService->getVendorsByIds([$accountPayable->vendor_id])->first();
}
// 嘗試加載來源單據資訊 (目前支援 goods_receipt)
$sourceDocumentCode = null;
if ($accountPayable->source_document_type === 'goods_receipt') {
$receipt = \App\Modules\Inventory\Models\GoodsReceipt::find($accountPayable->source_document_id);
if ($receipt) {
$sourceDocumentCode = $receipt->code;
$receiptData = app(\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class)
->getGoodsReceiptData($accountPayable->source_document_id);
if ($receiptData) {
$sourceDocumentCode = $receiptData['code'] ?? null;
}
}

View File

@@ -27,7 +27,11 @@ class AccountingReportController extends Controller
$allRecords = $reportData['records'];
// 3. Manual Pagination
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$page = $request->input('page', 1);
$offset = ($page - 1) * $perPage;

View File

@@ -41,14 +41,7 @@ class AccountPayable extends Model
'paid_at' => 'datetime',
];
/**
* 關聯:供應商
* @return BelongsTo
*/
public function vendor(): BelongsTo
{
return $this->belongsTo(\App\Modules\Procurement\Models\Vendor::class, 'vendor_id');
}
// vendor 關聯移至 service (跨模組)
/**
* 關聯:建立者

View File

@@ -50,8 +50,8 @@ class AccountPayableService
'total_amount' => collect($receiptData['items'] ?? [])->sum('total_amount'),
'tax_amount' => 0, // 假設後續會實作稅額計算,目前預設為 0
'status' => AccountPayable::STATUS_PENDING,
// 設定應付日期,預設為進貨後 30 天 (可依據供應商設定調整)
'due_date' => now()->addDays(30)->toDateString(),
// 設定應付日期,預設為進貨後天數 (由系統設定決定,預設 30 天)
'due_date' => now()->addDays(\App\Modules\Core\Models\SystemSetting::getVal('finance.ap_payment_days', 30))->toDateString(),
'created_by' => $userId,
'remarks' => "由進貨單 {$receiptData['code']} 自動生成",
]);

View File

@@ -94,7 +94,13 @@ class FinanceService implements FinanceServiceInterface
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
return $query->paginate($filters['per_page'] ?? 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) ($filters['per_page'] ?? $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
return $query->paginate($perPage);
}
public function getUniqueCategories(): Collection

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\JsonResponse;
class InventorySyncController extends Controller
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 提供外部 POS 查詢指定倉庫的商品庫存餘額
*
* @param string $warehouseCode
* @return JsonResponse
*/
public function show(string $warehouseCode): JsonResponse
{
// 透過 Service 調用跨模組庫存查詢功能
$inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode);
// 若回傳 null表示尋無此倉庫代碼
if (is_null($inventoryData)) {
return response()->json([
'status' => 'error',
'message' => "Warehouse with code '{$warehouseCode}' not found.",
], 404);
}
// 以 JSON 格式回傳組合好的商品庫存列表
return response()->json([
'status' => 'success',
'warehouse_code' => $warehouseCode,
'data' => $inventoryData->map(function ($item) {
return [
'external_pos_id' => $item->external_pos_id,
'product_code' => $item->product_code,
'product_name' => $item->product_name,
'quantity' => (float) $item->total_quantity,
];
})
], 200);
}
}

View File

@@ -29,7 +29,13 @@ class SalesOrderController extends Controller
// 排序
$query->orderBy('sold_at', 'desc');
$orders = $query->paginate($request->input('per_page', 10))
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) $request->input('per_page', $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$orders = $query->paginate($perPage)
->withQueryString();
return Inertia::render('Integration/SalesOrders/Index', [

View File

@@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
use App\Modules\Integration\Controllers\ProductSyncController;
use App\Modules\Integration\Controllers\OrderSyncController;
use App\Modules\Integration\Controllers\VendingOrderSyncController;
use App\Modules\Integration\Controllers\InventorySyncController;
Route::prefix('api/v1/integration')
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
@@ -11,4 +12,5 @@ Route::prefix('api/v1/integration')
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
Route::post('orders', [OrderSyncController::class, 'store']);
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
Route::get('inventory/{warehouse_code}', [InventorySyncController::class, 'show']);
});

View File

@@ -40,6 +40,14 @@ interface InventoryServiceInterface
*/
public function getProductsByIds(array $ids);
/**
* Get multiple warehouses by their codes.
*
* @param array $codes
* @return \Illuminate\Support\Collection
*/
public function getWarehousesByCodes(array $codes);
/**
* Search products by name.
*
@@ -123,7 +131,7 @@ interface InventoryServiceInterface
* @param int $perPage 每頁筆數
* @return array
*/
public function getStockQueryData(array $filters = [], int $perPage = 10): array;
public function getStockQueryData(array $filters = [], ?int $perPage = null): array;
/**
* Get statistics for the dashboard.
@@ -139,4 +147,41 @@ interface InventoryServiceInterface
* @return object
*/
public function findOrCreateWarehouseByName(string $warehouseName);
/**
* Get top inventory value for dashboard.
*/
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection;
/**
* Get items expiring soon for dashboard.
*/
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection;
/**
* Get inventory summary (group by product) for a specific warehouse code
*
* @param string $code
* @return \Illuminate\Support\Collection|null
*/
public function getPosInventoryByWarehouseCode(string $code);
/**
* 處理批量入庫邏輯 (含批號產生與現有批號累加)
*
* @param \App\Modules\Inventory\Models\Warehouse $warehouse
* @param array $items 入庫品項清單
* @param array $meta 資料包含 inboundDate, reason, notes
* @return void
*/
public function processIncomingInventory(\App\Modules\Inventory\Models\Warehouse $warehouse, array $items, array $meta): void;
/**
* 處理單一庫存項目的調整。
*
* @param \App\Modules\Inventory\Models\Inventory $inventory
* @param array $data 包含 quantity, operation, type, reason, unit_cost
* @return void
*/
public function adjustInventory(\App\Modules\Inventory\Models\Inventory $inventory, array $data): void;
}

View File

@@ -38,4 +38,44 @@ interface ProductServiceInterface
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByCodes(array $codes);
/**
* 建立新商品。
*
* @param array $data
* @return \App\Modules\Inventory\Models\Product
*/
public function createProduct(array $data);
/**
* 更新現有商品。
*
* @param \App\Modules\Inventory\Models\Product $product
* @param array $data
* @return \App\Modules\Inventory\Models\Product
*/
public function updateProduct(\App\Modules\Inventory\Models\Product $product, array $data);
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
*
* @return string
*/
public function generateRandomCode();
/**
* 生成隨機 13 碼條碼 (純數字)
*
* @return string
*/
public function generateRandomBarcode();
/**
* 根據條碼或代號查找商品。
*
* @param string|null $barcode
* @param string|null $code
* @return \App\Modules\Inventory\Models\Product|null
*/
public function findByBarcodeOrCode(?string $barcode, ?string $code);
}

View File

@@ -39,7 +39,11 @@ class AdjustDocController extends Controller
$query->where('warehouse_id', $request->warehouse_id);
}
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$docs = $query->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()

View File

@@ -35,9 +35,11 @@ class CountDocController extends Controller
});
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 10;
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$countQuery = function ($query) {

View File

@@ -7,11 +7,10 @@ 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 App\Modules\Inventory\Services\DuplicateCheckService;
use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt;
use Illuminate\Support\Facades\DB;
use App\Modules\Inventory\Services\DuplicateCheckService;
class GoodsReceiptController extends Controller
{
@@ -64,7 +63,11 @@ class GoodsReceiptController extends Controller
}
// 每頁筆數
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$receipts = $query->orderBy('created_at', 'desc')
->paginate($perPage)
@@ -89,7 +92,7 @@ class GoodsReceiptController extends Controller
]);
}
public function show($id)
public function show(Request $request, $id)
{
$receipt = GoodsReceipt::with([
'warehouse',
@@ -106,7 +109,12 @@ class GoodsReceiptController extends Controller
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
return Inertia::render('Inventory/GoodsReceipt/Show', [
'receipt' => $receipt
'receipt' => $receipt,
'navigation' => [
'from' => $request->query('from'),
'from_id' => $request->query('from_id'),
'from_label' => $request->query('from_label'),
]
]);
}

View File

@@ -24,7 +24,12 @@ class InventoryAnalysisController extends Controller
'warehouse_id', 'category_id', 'search', 'per_page', 'sort_by', 'sort_order', 'status'
]);
$analysisData = $this->turnoverService->getAnalysisData($filters, $request->input('per_page', 10));
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) $request->input('per_page', $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$analysisData = $this->turnoverService->getAnalysisData($filters, $perPage);
$kpis = $this->turnoverService->getKPIs($filters);
return Inertia::render('Inventory/Analysis/Index', [

View File

@@ -22,10 +22,14 @@ use App\Modules\Core\Contracts\CoreServiceInterface;
class InventoryController extends Controller
{
protected $coreService;
protected $inventoryService;
public function __construct(CoreServiceInterface $coreService)
{
public function __construct(
CoreServiceInterface $coreService,
\App\Modules\Inventory\Contracts\InventoryServiceInterface $inventoryService
) {
$this->coreService = $coreService;
$this->inventoryService = $inventoryService;
}
public function index(Request $request, Warehouse $warehouse)
@@ -182,97 +186,20 @@ class InventoryController extends Controller
]);
return DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) {
// ... (略,傳遞 unit_cost 交給 Service 處理) ...
// 這裡需要修改呼叫 Service 的地方或直接更新邏輯
// 為求快速,我將在此更新邏輯
$inventory = null;
if ($item['batchMode'] === 'existing') {
// 模式 A選擇現有批號 (包含已刪除的也要能找回來累加)
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
if ($inventory->trashed()) {
$inventory->restore();
}
// 更新成本 (若有傳入)
if (isset($item['unit_cost'])) {
$inventory->unit_cost = $item['unit_cost'];
}
} elseif ($item['batchMode'] === 'none') {
// 模式 C不使用批號 (自動累加至 NO-BATCH)
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => 'NO-BATCH'
],
[
'quantity' => 0,
'unit_cost' => $item['unit_cost'] ?? 0,
'total_value' => 0,
'arrival_date' => $validated['inboundDate'],
'expiry_date' => null,
'origin_country' => 'TW',
]
);
if ($inventory->trashed()) {
$inventory->restore();
}
} else {
// 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW';
$product = Product::find($item['productId']);
$batchNumber = Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$validated['inboundDate']
);
// 檢查是否存在
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => $batchNumber
],
[
'quantity' => 0,
'unit_cost' => $item['unit_cost'] ?? 0, // 新增
'total_value' => 0, // 稍後計算
'location' => $item['location'] ?? null,
'arrival_date' => $validated['inboundDate'],
'expiry_date' => $item['expiryDate'] ?? null,
'origin_country' => $originCountry,
]
);
if ($inventory->trashed()) {
$inventory->restore();
}
}
$currentQty = $inventory->quantity;
$newQty = $currentQty + $item['quantity'];
$inventory->quantity = $newQty;
// 更新總價值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
// 寫入異動紀錄
$inventory->transactions()->create([
'type' => '手動入庫',
'quantity' => $item['quantity'],
'unit_cost' => $inventory->unit_cost, // 記錄成本
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
'actual_time' => $validated['inboundDate'],
'user_id' => auth()->id(),
]);
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
$dt->setTimeFrom(now());
} else {
$dt->setSecond(now()->second);
}
$inboundDateTime = $dt->toDateTimeString();
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
'inboundDate' => $inboundDateTime,
'reason' => $validated['reason'],
'notes' => $validated['notes'] ?? '',
]);
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存記錄已儲存成功');
@@ -401,81 +328,7 @@ class InventoryController extends Controller
]);
return DB::transaction(function () use ($validated, $inventory) {
$currentQty = (float) $inventory->quantity;
$newQty = (float) $validated['quantity'];
// 判斷是否來自調整彈窗 (包含 operation 參數)
$isAdjustment = isset($validated['operation']);
$changeQty = 0;
if ($isAdjustment) {
switch ($validated['operation']) {
case 'add':
$changeQty = (float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'subtract':
$changeQty = -(float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'set':
$changeQty = $newQty - $currentQty;
break;
}
} else {
// 來自編輯頁面,直接 Set
$changeQty = $newQty - $currentQty;
}
// 更新成本 (若有傳)
if (isset($validated['unit_cost'])) {
$inventory->unit_cost = $validated['unit_cost'];
}
// 更新庫存
$inventory->quantity = $newQty;
// 更新總值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
// 異動類型映射
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
$typeMapping = [
'manual_adjustment' => '手動調整庫存',
'adjustment' => '盤點調整',
'purchase_in' => '採購進貨',
'sales_out' => '銷售出庫',
'return_in' => '退貨入庫',
'return_out' => '退貨出庫',
'transfer_in' => '撥補入庫',
'transfer_out' => '撥補出庫',
];
$chineseType = $typeMapping[$type] ?? $type;
// 如果是編輯頁面來的,且沒傳 type設為手動編輯
if (!$isAdjustment && !isset($validated['type'])) {
$chineseType = '手動編輯';
}
// 整理原因
$reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
if (isset($validated['notes'])) {
$reason .= ' - ' . $validated['notes'];
}
// 寫入異動紀錄
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
'type' => $chineseType,
'quantity' => $changeQty,
'unit_cost' => $inventory->unit_cost, // 記錄
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $reason,
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
$this->inventoryService->adjustInventory($inventory, $validated);
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
->with('success', '庫存資料已更新');

View File

@@ -35,7 +35,12 @@ class InventoryReportController extends Controller
$filters['date_to'] = date('Y-m-d');
}
$reportData = $this->reportService->getReportData($filters, $request->input('per_page', 10));
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) $request->input('per_page', $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$reportData = $this->reportService->getReportData($filters, $perPage);
$summary = $this->reportService->getSummary($filters);
return Inertia::render('Inventory/Report/Index', [

View File

@@ -16,6 +16,12 @@ use App\Modules\Inventory\Imports\ProductImport;
class ProductController extends Controller
{
protected $productService;
public function __construct(\App\Modules\Inventory\Contracts\ProductServiceInterface $productService)
{
$this->productService = $productService;
}
/**
* 顯示資源列表。
*/
@@ -37,9 +43,11 @@ class ProductController extends Controller
$query->where('category_id', $request->category_id);
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 10;
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$sortField = $request->input('sort_field', 'id');
@@ -193,15 +201,7 @@ class ProductController extends Controller
'is_active' => 'boolean',
]);
if (empty($validated['code'])) {
$validated['code'] = $this->generateRandomCode();
}
if (empty($validated['barcode'])) {
$validated['barcode'] = $this->generateRandomBarcode();
}
$product = Product::create($validated);
$product = $this->productService->createProduct($validated);
return redirect()->route('products.index')->with('success', '商品已建立');
}
@@ -260,15 +260,7 @@ class ProductController extends Controller
'is_active' => 'boolean',
]);
if (empty($validated['code'])) {
$validated['code'] = $this->generateRandomCode();
}
if (empty($validated['barcode'])) {
$validated['barcode'] = $this->generateRandomBarcode();
}
$product->update($validated);
$this->productService->updateProduct($product, $validated);
if ($request->input('from') === 'show') {
return redirect()->route('products.show', $product->id)->with('success', '商品已更新');
@@ -292,7 +284,7 @@ class ProductController extends Controller
*/
public function template()
{
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
return Excel::download(new ProductTemplateExport, '商品匯入範本.xlsx');
}
/**
@@ -318,39 +310,4 @@ class ProductController extends Controller
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
}
}
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
*/
private function generateRandomCode(): string
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$code = '';
do {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $characters[rand(0, strlen($characters) - 1)];
}
} while (Product::where('code', $code)->exists());
return $code;
}
/**
* 生成隨機 13 碼條碼 (純數字)
*/
private function generateRandomBarcode(): string
{
$barcode = '';
do {
$barcode = '';
for ($i = 0; $i < 13; $i++) {
$barcode .= rand(0, 9);
}
} while (Product::where('barcode', $barcode)->exists());
return $barcode;
}
}

View File

@@ -24,7 +24,12 @@ class StockQueryController extends Controller
public function index(Request $request)
{
$filters = $request->only(['warehouse_id', 'category_id', 'search', 'status', 'sort_by', 'sort_order', 'per_page']);
$perPage = (int) ($filters['per_page'] ?? 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) ($filters['per_page'] ?? $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$result = $this->inventoryService->getStockQueryData($filters, $perPage);

View File

@@ -65,7 +65,11 @@ class StoreRequisitionController extends Controller
$query->orderBy('id', 'desc');
}
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$requisitions = $query->paginate($perPage)->withQueryString();
// 水和倉庫名稱與使用者名稱
@@ -139,15 +143,16 @@ class StoreRequisitionController extends Controller
'items.*.requested_qty.min' => '需求數量必須大於 0',
]);
$submitImmediately = $request->boolean('submit_immediately');
$requisition = $this->service->create(
$request->only(['store_warehouse_id', 'remark']),
$request->items,
auth()->id()
auth()->id(),
$submitImmediately
);
// 如果需要直接提交
if ($request->boolean('submit_immediately')) {
$this->service->submit($requisition, auth()->id());
if ($submitImmediately) {
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已提交審核');
}
@@ -161,7 +166,10 @@ class StoreRequisitionController extends Controller
*/
public function show($id)
{
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
$requisition = StoreRequisition::with([
'items.product.baseUnit',
'transferOrder.items' // 載入產生的調撥單明細與批號
])->findOrFail($id);
// 水和倉庫
$warehouses = Warehouse::select('id', 'name', 'type')->get();
@@ -194,8 +202,69 @@ class StoreRequisitionController extends Controller
->get()
->keyBy('product_id');
$requisition->items->transform(function ($item) use ($inventories) {
// 取得供貨倉庫的可用庫存
$supplyInventories = collect();
$supplyBatchesMap = collect();
if ($requisition->supply_warehouse_id) {
$supplyInventories = Inventory::where('warehouse_id', $requisition->supply_warehouse_id)
->whereIn('product_id', $productIds)
->select('product_id')
->selectRaw('SUM(quantity) as total_qty')
->selectRaw('SUM(reserved_quantity) as total_reserved')
->groupBy('product_id')
->get()
->keyBy('product_id');
// 取得各商品的批號庫存
$batches = Inventory::where('warehouse_id', $requisition->supply_warehouse_id)
->whereIn('product_id', $productIds)
->whereRaw('(quantity - reserved_quantity) > 0') // 僅撈出還有可用庫存的批號
->select('id', 'product_id', 'batch_number', 'expiry_date', 'location as position')
->selectRaw('quantity - reserved_quantity as available_qty')
->get();
$supplyBatchesMap = $batches->groupBy('product_id');
}
// 把調撥單明細 (核准的批號與數量) 整理成 map, key 為 product_id
$approvedBatchesMap = collect();
if ($requisition->transferOrder) {
$approvedBatchesMap = $requisition->transferOrder->items->groupBy('product_id');
}
$requisition->items->transform(function ($item) use ($inventories, $supplyInventories, $supplyBatchesMap, $approvedBatchesMap) {
$item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0;
if ($supplyInventories->has($item->product_id)) {
$stock = $supplyInventories->get($item->product_id);
$item->supply_stock = max(0, $stock->total_qty - $stock->total_reserved);
// 附加該商品的批號可用庫存
$batches = $supplyBatchesMap->get($item->product_id) ?? collect();
$item->supply_batches = $batches->map(function ($batch) {
return [
'inventory_id' => $batch->id,
'batch_number' => $batch->batch_number,
'position' => $batch->position,
'available_qty' => $batch->available_qty,
'expiry_date' => $batch->expiry_date ? $batch->expiry_date->format('Y-m-d') : null,
];
})->values()->toArray();
} else {
$item->supply_stock = null;
$item->supply_batches = [];
}
// 附加已核准的批號資訊
$approvedBatches = $approvedBatchesMap->get($item->product_id) ?? collect();
$item->approved_batches = $approvedBatches->map(function ($transferItem) {
// 如果是沒有批號管控的商品batch_number 可能為 null
return [
'batch_number' => $transferItem->batch_number,
'qty' => $transferItem->quantity,
];
})->values()->toArray();
return $item;
});
@@ -302,6 +371,10 @@ class StoreRequisitionController extends Controller
'items' => 'required|array',
'items.*.id' => 'required|exists:store_requisition_items,id',
'items.*.approved_qty' => 'required|numeric|min:0',
'items.*.batches' => 'nullable|array',
'items.*.batches.*.inventory_id' => 'nullable|integer',
'items.*.batches.*.batch_number' => 'nullable|string',
'items.*.batches.*.qty' => 'required_with:items.*.batches|numeric|min:0.01',
]);
if (empty($requisition->supply_warehouse_id)) {

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Modules\Inventory\Services\TraceabilityService;
class TraceabilityController extends Controller
{
public function __construct(
protected TraceabilityService $traceabilityService
) {}
/**
* 顯示批號溯源查詢的主頁面
*/
public function index(Request $request)
{
$batchNumber = $request->input('batch_number');
$direction = $request->input('direction', 'backward'); // backward 或 forward
$result = null;
if ($batchNumber) {
if ($direction === 'backward') {
$result = $this->traceabilityService->traceBackward($batchNumber);
} else {
$result = $this->traceabilityService->traceForward($batchNumber);
}
}
return Inertia::render('Inventory/Traceability/Index', [
'search' => [
'batch_number' => $batchNumber,
'direction' => $direction,
],
'result' => $result
]);
}
}

View File

@@ -42,7 +42,11 @@ class TransferOrderController extends Controller
});
}
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$orders = $query->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()
@@ -95,6 +99,24 @@ class TransferOrderController extends Controller
auth()->id(),
$transitWarehouseId
);
// 手動發送「已建立」日誌,因為服務層使用了 saveQuietly 抑制自動日誌
activity()
->performedOn($order)
->causedBy(auth()->id())
->event('created')
->withProperties([
'attributes' => [
'doc_no' => $order->doc_no,
'from_warehouse_id' => $order->from_warehouse_id,
'to_warehouse_id' => $order->to_warehouse_id,
'transit_warehouse_id' => $order->transit_warehouse_id,
'remarks' => $order->remarks,
'status' => $order->status,
'created_by' => $order->created_by,
]
])
->log('created');
if ($request->input('instant_post') === true) {
try {
@@ -211,6 +233,9 @@ class TransferOrderController extends Controller
// 2. 先更新資料 (如果請求中包含 items則先執行儲存)
$itemsChanged = false;
if ($request->has('items')) {
if ($order->storeRequisition()->exists()) {
return redirect()->back()->with('error', '由叫貨單自動產生的調撥單無法修改明細');
}
$validated = $request->validate([
'items' => 'array',
'items.*.product_id' => 'required|exists:products,id',
@@ -259,6 +284,17 @@ class TransferOrderController extends Controller
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
}
// 刪除前必須先釋放預留庫存
foreach ($order->items as $item) {
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
if ($inv) {
$inv->releaseReservedQuantity($item->quantity);
}
}
$order->items()->delete();
$order->delete();

View File

@@ -24,9 +24,11 @@ class WarehouseController extends Controller
});
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 10;
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和

View File

@@ -23,7 +23,11 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
{
HeadingRowFormatter::default('none');
$this->warehouse = $warehouse;
$this->inboundDate = $inboundDate;
// 修正時間精度:將選定的日期與「現在的時分秒」結合
// 這樣既能保留使用者選的日期,又能提供精確的紀錄時點排順序
$this->inboundDate = \Illuminate\Support\Carbon::parse($inboundDate)->setTimeFrom(now())->toDateTimeString();
$this->notes = $notes;
}
@@ -95,7 +99,7 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
// 更新單價與總價值
$inventory->unit_cost = $unitCost;
$inventory->total_value = $inventory->quantity * $unitCost;
$inventory->save();
$inventory->saveQuietly();
// 記錄交易歷史
$inventory->transactions()->create([

View File

@@ -9,47 +9,88 @@ use Illuminate\Validation\Rule;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapping
/**
* 商品匯入主類別
*
* 實作 WithMultipleSheets 以限定只讀取第一個工作表(資料頁),
* 跳過第二個工作表(填寫說明頁),避免說明頁的資料被誤匯入並觸發驗證錯誤。
*/
class ProductImport implements WithMultipleSheets
{
private $categories;
private $units;
public function __construct()
{
// 禁用標題格式化,保留中文標題
HeadingRowFormatter::default('none');
}
/**
* 指定只處理第一個工作表 (index 0)
*/
public function sheets(): array
{
return [
0 => new ProductDataSheetImport(),
];
}
}
/**
* 商品匯入 - 資料工作表處理類別
*
* 負責實際的資料解析、驗證與儲存邏輯。
* 只會被套用到 Excel 的第一個工作表(資料頁)。
*/
class ProductDataSheetImport implements ToModel, WithHeadingRow, WithValidation, WithMapping, SkipsEmptyRows
{
private $categories;
private $units;
private $productService;
public function __construct()
{
// 快取所有類別與單位,避免 N+1 查詢
$this->categories = Category::pluck('id', 'name');
$this->units = Unit::pluck('id', 'name');
$this->productService = app(\App\Modules\Inventory\Contracts\ProductServiceInterface::class);
}
/**
* @param mixed $row
*
* @return array
*/
* 資料映射:將 Excel 原始標題(含「(選填)」)對應到乾淨的鍵名
*
* 注意WithValidation 驗證的是 map() 之前的原始資料,
* 因此 rules() 中的鍵名必須匹配 Excel 的原始標題。
* map() 的返回值只影響 model() 接收到的資料。
*/
public function map($row): array
{
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
if (isset($row['商品代號'])) {
$row['商品代號'] = (string) $row['商品代號'];
}
if (isset($row['條碼'])) {
$row['條碼'] = (string) $row['條碼'];
}
return $row;
$code = $row['商品代號(選填)'] ?? $row['商品代號'] ?? null;
$barcode = $row['條碼(選填)'] ?? $row['條碼'] ?? null;
return [
'商品代號' => $code !== null ? (string)$code : null,
'條碼' => $barcode !== null ? (string)$barcode : null,
'商品名稱' => $row['商品名稱'] ?? null,
'類別名稱' => $row['類別名稱'] ?? null,
'品牌' => $row['品牌'] ?? null,
'規格' => $row['規格'] ?? null,
'基本單位' => $row['基本單位'] ?? null,
'大單位' => $row['大單位'] ?? null,
'換算率' => isset($row['換算率']) ? (float)$row['換算率'] : null,
'成本價' => isset($row['成本價']) ? (float)$row['成本價'] : null,
'售價' => isset($row['售價']) ? (float)$row['售價'] : null,
'會員價' => isset($row['會員價']) ? (float)$row['會員價'] : null,
'批發價' => isset($row['批發價']) ? (float)$row['批發價'] : null,
];
}
/**
* @param array $row
*
* @return \Illuminate\Database\Eloquent\Model|null
*/
* @param array $row (map() 回傳的乾淨鍵名陣列)
*/
public function model(array $row)
{
// 查找關聯 ID
@@ -65,15 +106,8 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
$code = $row['商品代號'] ?? null;
$barcode = $row['條碼'] ?? null;
// Upsert 邏輯:優先以條碼查找,次之以商品代號查找
$product = null;
if (!empty($barcode)) {
$product = Product::where('barcode', $barcode)->first();
}
if (!$product && !empty($code)) {
$product = Product::where('code', $code)->first();
}
// Upsert 邏輯:透過 Service 統一查找與處理
$product = $this->productService->findByBarcodeOrCode($barcode, $code);
$data = [
'name' => $row['商品名稱'],
@@ -91,65 +125,27 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
];
if ($product) {
// 更新現有商品
$product->update($data);
return null; // 返回 null 以避免 Maatwebsite/Excel 嘗試再次 insert
$this->productService->updateProduct($product, $data);
} else {
if (!empty($code)) $data['code'] = $code;
if (!empty($barcode)) $data['barcode'] = $barcode;
$this->productService->createProduct($data);
}
// 建立新商品:處理代碼與條碼自動生成
if (empty($code)) {
$code = $this->generateRandomCode();
}
if (empty($barcode)) {
$barcode = $this->generateRandomBarcode();
}
$data['code'] = $code;
$data['barcode'] = $barcode;
return new Product($data);
return null; // 返回 null因為 Service 已經處理完儲存
}
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
* 驗證規則
*
* 鍵名必須匹配 Excel 原始標題(含「(選填)」後綴),
* 因為 WithValidation 驗證的是 map() 之前的原始資料。
*/
private function generateRandomCode(): string
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$code = '';
do {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $characters[rand(0, strlen($characters) - 1)];
}
} while (Product::where('code', $code)->exists());
return $code;
}
/**
* 生成隨機 13 碼條碼 (純數字)
*/
private function generateRandomBarcode(): string
{
$barcode = '';
do {
$barcode = '';
for ($i = 0; $i < 13; $i++) {
$barcode .= rand(0, 9);
}
} while (Product::where('barcode', $barcode)->exists());
return $barcode;
}
public function rules(): array
{
return [
'商品代號' => ['nullable', 'string', 'min:2', 'max:8'],
'條碼' => ['nullable', 'string'],
'商品代號(選填)' => ['nullable', 'string', 'min:2', 'max:8'],
'條碼(選填)' => ['nullable', 'string'],
'商品名稱' => ['required', 'string'],
'類別名稱' => ['required', function($attribute, $value, $fail) {
if (!isset($this->categories[$value])) {
@@ -174,4 +170,16 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
'批發價' => ['nullable', 'numeric', 'min:0'],
];
}
/**
* 自訂驗證錯誤訊息的欄位名稱
* 把含 "(選填)" 後綴的欄位顯示為友善名稱
*/
public function customValidationAttributes(): array
{
return [
'商品代號(選填)' => '商品代號',
'條碼(選填)' => '條碼',
];
}
}

View File

@@ -29,12 +29,27 @@ class Category extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
}

View File

@@ -40,6 +40,47 @@ class GoodsReceipt extends Model
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['doc_no'] = $this->code;
$snapshot['warehouse_name'] = $this->warehouse?->name;
if (!isset($snapshot['vendor_name']) && $this->vendor_id) {
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
->getVendorsByIds([$this->vendor_id])->first();
$snapshot['vendor_name'] = $vendor?->name;
}
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = app(\App\Modules\Core\Contracts\CoreServiceInterface::class)->getUser($data[$f])?->name;
}
}
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
}
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
->getVendorsByIds([$data['vendor_id']])->first();
$data['vendor_id'] = $vendor?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
public function items()
{
return $this->hasMany(GoodsReceiptItem::class);

View File

@@ -17,6 +17,7 @@ class Inventory extends Model
'warehouse_id',
'product_id',
'quantity',
'reserved_quantity',
'location',
'unit_cost',
'total_value',
@@ -34,6 +35,8 @@ class Inventory extends Model
protected $casts = [
'arrival_date' => 'date:Y-m-d',
'expiry_date' => 'date:Y-m-d',
'quantity' => 'decimal:4',
'reserved_quantity' => 'decimal:4',
'unit_cost' => 'decimal:4',
'total_value' => 'decimal:4',
];
@@ -55,8 +58,11 @@ class Inventory extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$attributes = $properties['attributes'] ?? [];
// 核心:轉換為陣列以避免 Indirect modification error
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
// 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改
@@ -66,11 +72,28 @@ class Inventory extends Model
// 如果已設定原因,則進行捕捉
if ($this->activityLogReason) {
$attributes['_reason'] = $this->activityLogReason;
$properties['attributes']['_reason'] = $this->activityLogReason;
}
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
// 全域 ID 轉名稱邏輯
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 倉庫 ID 轉換
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
}
// 商品 ID 轉換
if (isset($data['product_id']) && is_numeric($data['product_id'])) {
$data['product_id'] = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
@@ -109,7 +132,33 @@ class Inventory extends Model
});
}
/**
* 可用庫存(實體庫存 - 預留庫存)
*/
public function getAvailableQuantityAttribute()
{
return max(0, $this->quantity - $this->reserved_quantity);
}
/**
* 增加預留庫存(鎖定)
*/
public function reserveQuantity(float|int $amount)
{
if ($amount <= 0) return;
$this->reserved_quantity += $amount;
$this->saveQuietly();
}
/**
* 釋放預留庫存(解鎖)
*/
public function releaseReservedQuantity(float|int $amount)
{
if ($amount <= 0) return;
$this->reserved_quantity = max(0, $this->reserved_quantity - $amount);
$this->saveQuietly();
}
/**
* 產生批號

View File

@@ -10,6 +10,7 @@ class InventoryTransaction extends Model
{
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
use HasFactory;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'inventory_id',
@@ -41,4 +42,49 @@ class InventoryTransaction extends Model
{
return $this->morphTo();
}
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->dontLogIfAttributesChangedOnly(['updated_at'])
// 取消 logOnlyDirty代表新增時(created)也要留紀錄
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
// 試著取得商品與倉庫名稱來作為主要顯示依據
$inventory = $this->inventory;
if ($inventory) {
$snapshot['warehouse_name'] = $inventory->warehouse ? $inventory->warehouse->name : null;
$snapshot['product_name'] = $inventory->product ? $inventory->product->name : null;
$snapshot['batch_number'] = $inventory->batch_number;
}
// 把異動類型與數量也拉到 snapshot
$snapshot['type'] = $this->type;
$snapshot['quantity'] = $this->quantity;
$snapshot['reason'] = $this->reason;
// 替換使用者名稱
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
if (isset($data['user_id']) && is_numeric($data['user_id'])) {
$data['user_id'] = \App\Modules\Core\Models\User::find($data['user_id'])?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
}

View File

@@ -36,21 +36,23 @@ class InventoryTransferOrder extends Model
if ($eventName === 'created') {
$activity->description = 'created';
} elseif ($eventName === 'updated') {
// 如果屬性中有 status 且變更為 completed將描述改為 posted
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
$activity->description = 'posted';
$eventName = 'posted'; // 供後續快照邏輯判定
$eventName = 'posted';
} else {
$activity->description = 'updated';
}
}
// 處理倉庫 ID 轉名稱
// 處理 ID 轉名稱 (核心:支援 attributes 與 old 的自動轉換)
$idToNameFields = [
'from_warehouse_id' => 'fromWarehouse',
'to_warehouse_id' => 'toWarehouse',
'transit_warehouse_id' => 'transitWarehouse',
'created_by' => 'createdBy',
'posted_by' => 'postedBy',
'dispatched_by' => 'dispatchedBy',
'received_by' => 'receivedBy',
];
foreach (['attributes', 'old'] as $part) {
@@ -58,14 +60,20 @@ class InventoryTransferOrder extends Model
foreach ($idToNameFields as $idField => $relation) {
if (isset($properties[$part][$idField])) {
$id = $properties[$part][$idField];
$nameField = str_replace('_id', '_name', $idField);
if (!$id) continue;
$nameField = str_replace('_id', '_name', $idField);
$name = null;
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
$name = $this->$relation->name;
} else {
$model = $this->$relation()->getRelated()->find($id);
$name = $model ? $model->name : "ID: $id";
try {
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
$name = $this->$relation->name;
} else {
$relatedModel = $this->$relation()->getRelated();
$model = $relatedModel->find($id);
$name = $model ? ($model->name ?? $model->display_name ?? "ID: $id") : "ID: $id";
}
} catch (\Exception $e) {
$name = "ID: $id";
}
$properties[$part][$nameField] = $name;
}
@@ -73,7 +81,7 @@ class InventoryTransferOrder extends Model
}
}
// 基本單據資訊快照 (包含單號、來源、目的地)
// 基本單據資訊快照
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
@@ -85,8 +93,6 @@ class InventoryTransferOrder extends Model
// 移除輔助欄位與雜訊
if (isset($properties['attributes'])) {
unset($properties['attributes']['from_warehouse_name']);
unset($properties['attributes']['to_warehouse_name']);
unset($properties['attributes']['activityProperties']);
unset($properties['attributes']['updated_at']);
}
@@ -94,7 +100,7 @@ class InventoryTransferOrder extends Model
unset($properties['old']['updated_at']);
}
// 合併暫存屬性 (例如 items_diff)
// 合併暫存屬性 (重要:例如 items_diff)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}

View File

@@ -85,30 +85,50 @@ class Product extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$attributes = $properties['attributes'] ?? [];
// 核心:轉換為陣列以避免 Indirect modification error
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
// 處理分類名稱快照
if (isset($attributes['category_id'])) {
$category = Category::find($attributes['category_id']);
$snapshot['category_name'] = $category ? $category->name : null;
}
// 處理單位名稱快照
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
foreach ($unitFields as $field) {
if (isset($attributes[$field])) {
$unit = Unit::find($attributes[$field]);
$nameKey = str_replace('_id', '_name', $field);
$snapshot[$nameKey] = $unit ? $unit->name : null;
}
}
// 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂"
$snapshot['name'] = $this->name;
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
// 全域 ID 轉名稱邏輯
$resolver = function (&$data) use (&$snapshot) {
if (empty($data) || !is_array($data)) return;
// 處理分類名稱
if (isset($data['category_id']) && is_numeric($data['category_id'])) {
$categoryName = Category::find($data['category_id'])?->name;
$data['category_id'] = $categoryName;
if (!isset($snapshot['category_name']) && $categoryName) {
$snapshot['category_name'] = $categoryName;
}
}
// 處理單位名稱
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
foreach ($unitFields as $field) {
if (isset($data[$field]) && is_numeric($data[$field])) {
$unitName = Unit::find($data[$field])?->name;
$data[$field] = $unitName;
$nameKey = str_replace('_id', '_name', $field);
if (!isset($snapshot[$nameKey]) && $unitName) {
$snapshot[$nameKey] = $unitName;
}
}
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
// 因為 resolver 內部可能更新了 snapshot所以再覆寫一次
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}

View File

@@ -41,6 +41,11 @@ class StoreRequisition extends Model
->dontSubmitEmptyLogs();
}
/**
* @var array 暫存的活動紀錄屬性 (不會存入資料庫)
*/
public $activityProperties = [];
/**
* 自定義日誌屬性,解析 ID 為名稱
*/
@@ -48,22 +53,90 @@ class StoreRequisition extends Model
{
$properties = $activity->properties->toArray();
// 處置日誌事件與狀態中文化
$statusMap = [
'draft' => '草稿',
'pending' => '待審核',
'approved' => '已核准',
'rejected' => '已駁回',
'completed' => '已完成',
];
// 處理 ID 轉名稱
$idToNameFields = [
'store_warehouse_id' => 'storeWarehouse',
'supply_warehouse_id' => 'supplyWarehouse',
'created_by' => 'createdBy',
'approved_by' => 'approvedBy',
'transfer_order_id' => 'transferOrder',
];
foreach (['attributes', 'old'] as $part) {
if (isset($properties[$part])) {
// 1. 解析狀態中文並替換原始 status 欄位
if (isset($properties[$part]['status'])) {
$statusValue = $properties[$part]['status'];
$properties[$part]['status'] = $statusMap[$statusValue] ?? $statusValue;
}
// 2. 解析關連名稱
foreach ($idToNameFields as $idField => $relation) {
if (isset($properties[$part][$idField])) {
$id = $properties[$part][$idField];
if (!$id) continue;
$nameField = str_replace('_id', '_name', $idField);
if (str_contains($idField, '_by')) {
$nameField = str_replace('_by', '_user_name', $idField);
}
$name = null;
try {
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
// 特別處理調撥單號
$name = ($relation === 'transferOrder') ? $this->$relation->doc_no : $this->$relation->name;
} else {
$relatedModel = $this->$relation()->getRelated();
$model = $relatedModel->find($id);
if ($model) {
$name = ($relation === 'transferOrder') ? ($model->doc_no ?? "ID: $id") : ($model->name ?? "ID: $id");
} else {
$name = "ID: $id";
}
}
} catch (\Exception $e) {
$name = "ID: $id";
}
$properties[$part][$nameField] = $name;
// 移除原生的技術 ID 欄位,讓詳情更乾淨
unset($properties[$part][$idField]);
}
}
}
}
// 基本單據資訊快照
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
'store_warehouse_name' => $this->storeWarehouse?->name,
'supply_warehouse_name' => $this->supplyWarehouse?->name,
'status' => $this->status,
'status' => $statusMap[$this->status] ?? $this->status,
];
// 移除雜訊欄位
// 移除雜訊與重複欄位
if (isset($properties['attributes'])) {
unset($properties['attributes']['updated_at']);
unset($properties['attributes']['activityProperties']);
}
if (isset($properties['old'])) {
unset($properties['old']['updated_at']);
}
// 合併暫存屬性 (例如 items_diff)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}
$activity->properties = collect($properties);
}

View File

@@ -34,12 +34,27 @@ class Unit extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
}

View File

@@ -37,12 +37,31 @@ class Warehouse extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
if (isset($data['default_transit_warehouse_id']) && is_numeric($data['default_transit_warehouse_id'])) {
$data['default_transit_warehouse_id'] = self::find($data['default_transit_warehouse_id'])?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}

View File

@@ -38,6 +38,11 @@ Route::middleware('auth')->group(function () {
Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index');
});
// 批號溯源 (Lot Traceability)
Route::middleware('permission:inventory_traceability.view')->group(function () {
Route::get('/inventory/traceability', [\App\Modules\Inventory\Controllers\TraceabilityController::class, 'index'])->name('inventory.traceability.index');
});
// 類別管理 (用於商品對話框) - 需要商品權限
Route::middleware('permission:products.view')->group(function () {
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');

View File

@@ -5,10 +5,17 @@ use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\InventoryAdjustDoc;
use App\Modules\Inventory\Models\InventoryAdjustItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Facades\DB;
class AdjustService
{
protected InventoryServiceInterface $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
{
return InventoryAdjustDoc::create([
@@ -161,29 +168,20 @@ class AdjustService
'batch_number' => $item->batch_number,
]);
// 如果是新建立的 object (id 為空),需要初始化 default
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
if (!$inventory->exists) {
$inventory->unit_cost = $item->product->cost ?? 0;
$inventory->quantity = 0;
$inventory->total_value = 0;
$inventory->saveQuietly();
}
$oldQty = $inventory->quantity;
$newQty = $oldQty + $item->adjust_qty;
$inventory->quantity = $newQty;
$inventory->total_value = $newQty * $inventory->unit_cost;
$inventory->save();
// 建立 Transaction
$inventory->transactions()->create([
'type' => '庫存調整',
$this->inventoryService->adjustInventory($inventory, [
'operation' => 'add',
'quantity' => $item->adjust_qty,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $oldQty,
'balance_after' => $newQty,
'type' => 'adjustment',
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
'actual_time' => now(),
'user_id' => $userId,
'notes' => $item->notes,
]);
}

View File

@@ -38,10 +38,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
$data['user_id'] = auth()->id();
$data['status'] = GoodsReceipt::STATUS_DRAFT; // 預設草稿
// 2. Create Header
$goodsReceipt = GoodsReceipt::create($data);
// 2. 靜默建立以抑制自動日誌(後續手動發送含品項明細的日誌)
$goodsReceipt = new GoodsReceipt($data);
$goodsReceipt->saveQuietly();
// 3. 建立品項並收集 items_diff
$diff = ['added' => [], 'removed' => [], 'updated' => []];
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 3. Process Items
foreach ($data['items'] as $itemData) {
// Create GR Item
$grItem = new GoodsReceiptItem([
@@ -54,20 +59,43 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
'expiry_date' => $itemData['expiry_date'] ?? null,
]);
$goodsReceipt->items()->save($grItem);
$product = $products->get($itemData['product_id']);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity_received' => (float)$itemData['quantity_received'],
'unit_price' => (float)$itemData['unit_price'],
'total_amount' => (float)($itemData['quantity_received'] * $itemData['unit_price']),
]
];
}
// 4. 手動發送高品質日誌(包含品項明細)
activity()
->performedOn($goodsReceipt)
->causedBy(auth()->user())
->event('created')
->withProperties([
'items_diff' => $diff,
'attributes' => [
'gr_number' => $goodsReceipt->code,
'type' => $goodsReceipt->type,
'warehouse_id' => $goodsReceipt->warehouse_id,
'vendor_id' => $goodsReceipt->vendor_id,
'purchase_order_id' => $goodsReceipt->purchase_order_id,
'received_date' => $goodsReceipt->received_date,
'status' => $goodsReceipt->status,
'remarks' => $goodsReceipt->remarks,
'user_id' => $goodsReceipt->user_id,
]
])
->log('created');
return $goodsReceipt;
});
}
/**
* Update an existing Goods Receipt.
*
* @param GoodsReceipt $goodsReceipt
* @param array $data
* @return GoodsReceipt
* @throws \Exception
*/
public function update(GoodsReceipt $goodsReceipt, array $data)
{
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
@@ -75,14 +103,42 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
return DB::transaction(function () use ($goodsReceipt, $data) {
$goodsReceipt->update([
$goodsReceipt->fill([
'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id,
'received_date' => $data['received_date'] ?? $goodsReceipt->received_date,
'remarks' => $data['remarks'] ?? $goodsReceipt->remarks,
]);
$dirty = $goodsReceipt->getDirty();
$oldAttributes = [];
$newAttributes = [];
foreach ($dirty as $key => $value) {
$oldAttributes[$key] = $goodsReceipt->getOriginal($key);
$newAttributes[$key] = $value;
}
// 儲存但不觸發事件,以避免重複記錄
$goodsReceipt->saveQuietly();
// 捕捉包含商品名稱的舊項目以進行比對
$oldItemsCollection = $goodsReceipt->items()->get();
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
$product = $oldProducts->get($item->product_id);
return [
'id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $product?->name ?? 'Unknown',
'quantity_received' => (float) $item->quantity_received,
'unit_price' => (float) $item->unit_price,
'total_amount' => (float) $item->total_amount,
];
})->keyBy('product_id');
if (isset($data['items'])) {
// Simple strategy: delete existing items and recreate
$goodsReceipt->items()->delete();
foreach ($data['items'] as $itemData) {
@@ -99,6 +155,75 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
}
// 計算項目差異
$itemDiffs = [
'added' => [],
'removed' => [],
'updated' => [],
];
$newItemsCollection = $goodsReceipt->items()->get();
$newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray();
$newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id');
$newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) {
$product = $newProducts->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $product?->name ?? 'Unknown',
'quantity_received' => (float) $item->quantity_received,
'unit_price' => (float) $item->unit_price,
'total_amount' => (float) $item->total_amount,
];
})->keyBy('product_id');
foreach ($oldItems as $productId => $oldItem) {
if (!$newItemsFormatted->has($productId)) {
$itemDiffs['removed'][] = $oldItem;
}
}
foreach ($newItemsFormatted as $productId => $newItem) {
if (!$oldItems->has($productId)) {
$itemDiffs['added'][] = $newItem;
} else {
$oldItem = $oldItems[$productId];
if (
$oldItem['quantity_received'] != $newItem['quantity_received'] ||
$oldItem['unit_price'] != $newItem['unit_price'] ||
$oldItem['total_amount'] != $newItem['total_amount']
) {
$itemDiffs['updated'][] = [
'product_name' => $newItem['product_name'],
'old' => [
'quantity_received' => $oldItem['quantity_received'],
'unit_price' => $oldItem['unit_price'],
'total_amount' => $oldItem['total_amount'],
],
'new' => [
'quantity_received' => $newItem['quantity_received'],
'unit_price' => $newItem['unit_price'],
'total_amount' => $newItem['total_amount'],
]
];
}
}
}
// 如果有變更,手動觸發單一合併日誌
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
activity()
->performedOn($goodsReceipt)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => $newAttributes,
'old' => $oldAttributes,
'items_diff' => $itemDiffs,
])
->log('updated');
}
return $goodsReceipt->fresh('items');
});
}
@@ -162,23 +287,35 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
private function generateCode(string $date)
private function generateCode(string $date): string
{
// Format: GR-YYYYMMDD-NN
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc')
->lockForUpdate()
->first();
// 使用 Cache Lock 防止併發時產生重複單號
$lock = \Illuminate\Support\Facades\Cache::lock('gr_code_generation', 10);
if ($last) {
$seq = intval(substr($last->code, -2)) + 1;
} else {
$seq = 1;
if (!$lock->get()) {
throw new \Exception('系統忙碌中,進貨單號生成失敗,請稍後再試');
}
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
try {
// Format: GR-YYYYMMDD-NN
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc')
->first();
if ($last) {
$seq = intval(substr($last->code, -2)) + 1;
} else {
$seq = 1;
}
$code = $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
return $code;
} finally {
$lock->release();
}
}
/**

View File

@@ -23,8 +23,9 @@ class InventoryReportService
* @param int|null $perPage 每頁筆數
* @return \Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Support\Collection
*/
public function getReportData(array $filters, ?int $perPage = 10)
public function getReportData(array $filters, ?int $perPage = null)
{
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$dateFrom = $filters['date_from'] ?? null;
$dateTo = $filters['date_to'] ?? null;
$warehouseId = $filters['warehouse_id'] ?? null;
@@ -197,8 +198,9 @@ class InventoryReportService
/**
* 取得特定商品的庫存異動明細
*/
public function getProductDetails($productId, array $filters, ?int $perPage = 20)
public function getProductDetails($productId, array $filters, ?int $perPage = null)
{
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$dateFrom = $filters['date_from'] ?? null;
$dateTo = $filters['date_to'] ?? null;
$warehouseId = $filters['warehouse_id'] ?? null;

View File

@@ -16,6 +16,26 @@ class InventoryService implements InventoryServiceInterface
return Warehouse::all();
}
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
{
return Inventory::select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value'))
->where('quantity', '>', 0)
->groupBy('product_id')
->orderByDesc('total_value')
->limit($limit)
->get();
}
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection
{
return Inventory::where('quantity', '>', 0)
->whereNotNull('expiry_date')
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
->orderBy('expiry_date', 'asc')
->limit($limit)
->get();
}
public function getAllProducts()
{
return Product::with(['baseUnit', 'largeUnit'])->get();
@@ -41,6 +61,11 @@ class InventoryService implements InventoryServiceInterface
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
}
public function getWarehousesByCodes(array $codes)
{
return Warehouse::whereIn('code', $codes)->get();
}
public function getProductsByName(string $name)
{
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
@@ -156,11 +181,11 @@ class InventoryService implements InventoryServiceInterface
// 更新其他可能變更的欄位 (如最後入庫日)
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
$inventory->save();
$inventory->saveQuietly();
} else {
// 若不存在,則建立新紀錄
$unitCost = $data['unit_cost'] ?? 0;
$inventory = Inventory::create([
$inventory = new Inventory([
'warehouse_id' => $data['warehouse_id'],
'product_id' => $data['product_id'],
'quantity' => $data['quantity'],
@@ -174,9 +199,10 @@ class InventoryService implements InventoryServiceInterface
'quality_status' => $data['quality_status'] ?? 'normal',
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
]);
$inventory->saveQuietly();
}
\App\Modules\Inventory\Models\InventoryTransaction::create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => '入庫',
'quantity' => $data['quantity'],
@@ -189,6 +215,7 @@ class InventoryService implements InventoryServiceInterface
'user_id' => auth()->id(),
'actual_time' => now(),
]);
$transaction->saveQuietly();
return $inventory;
});
@@ -200,13 +227,12 @@ class InventoryService implements InventoryServiceInterface
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
$balanceBefore = $inventory->quantity;
$inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
// 需要手動更新總價值
$inventory->refresh();
// 手動更新以配合 saveQuietly 消除日誌
$inventory->quantity -= $quantity;
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
$inventory->saveQuietly();
\App\Modules\Inventory\Models\InventoryTransaction::create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => '出庫',
'quantity' => -$quantity,
@@ -219,6 +245,7 @@ class InventoryService implements InventoryServiceInterface
'user_id' => auth()->id(),
'actual_time' => now(),
]);
$transaction->saveQuietly();
});
}
@@ -233,10 +260,12 @@ class InventoryService implements InventoryServiceInterface
/**
* 即時庫存查詢:統計卡片 + 分頁明細
*/
public function getStockQueryData(array $filters = [], int $perPage = 10): array
public function getStockQueryData(array $filters = [], ?int $perPage = null): array
{
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$today = now()->toDateString();
$expiryThreshold = now()->addDays(30)->toDateString();
$expiryDays = \App\Modules\Core\Models\SystemSetting::getVal('inventory.expiry_warning_days', 30);
$expiryThreshold = now()->addDays($expiryDays)->toDateString();
// 基礎查詢
$query = Inventory::query()
@@ -467,7 +496,8 @@ class InventoryService implements InventoryServiceInterface
public function getDashboardStats(): array
{
$today = now()->toDateString();
$expiryThreshold = now()->addDays(30)->toDateString();
$expiryDays = \App\Modules\Core\Models\SystemSetting::getVal('inventory.expiry_warning_days', 30);
$expiryThreshold = now()->addDays($expiryDays)->toDateString();
// 1. 庫存品項數 (明細總數)
$totalItems = DB::table('inventories')
@@ -616,4 +646,200 @@ class InventoryService implements InventoryServiceInterface
]
);
}
/**
* 取得特定倉庫代碼的所屬商品總庫存 ( POS/外部系統同步使用)
*
* @param string $code
* @return \Illuminate\Support\Collection|null
*/
public function getPosInventoryByWarehouseCode(string $code)
{
$warehouse = Warehouse::where('code', $code)->first();
if (!$warehouse) {
return null;
}
// 整理該倉庫的庫存,以 product_id 進行 GROUP BY 並加總 quantity
return DB::table('inventories')
->join('products', 'inventories.product_id', '=', 'products.id')
->where('inventories.warehouse_id', $warehouse->id)
->whereNull('inventories.deleted_at')
->whereNull('products.deleted_at')
->select(
'products.external_pos_id',
'products.code as product_code',
'products.name as product_name',
DB::raw('SUM(inventories.quantity) as total_quantity')
)
->groupBy('inventories.product_id', 'products.external_pos_id', 'products.code', 'products.name')
->get();
}
public function processIncomingInventory(Warehouse $warehouse, array $items, array $meta): void
{
DB::transaction(function () use ($warehouse, $items, $meta) {
foreach ($items as $item) {
$inventory = null;
if ($item['batchMode'] === 'existing') {
// 模式 A選擇現有批號 (包含已刪除的也要能找回來累加)
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
if ($inventory->trashed()) {
$inventory->restore();
}
// 更新成本 (若有傳入)
if (isset($item['unit_cost'])) {
$inventory->unit_cost = $item['unit_cost'];
}
} elseif ($item['batchMode'] === 'none') {
// 模式 C不使用批號 (自動累加至 NO-BATCH)
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => 'NO-BATCH'
],
[
'quantity' => 0,
'unit_cost' => $item['unit_cost'] ?? 0,
'total_value' => 0,
'arrival_date' => $meta['inboundDate'],
'origin_country' => 'TW',
]
);
if ($inventory->trashed()) {
$inventory->restore();
}
} else {
// 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW';
$product = Product::find($item['productId']);
$batchNumber = Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$meta['inboundDate']
);
// 檢查是否存在
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => $batchNumber
],
[
'quantity' => 0,
'unit_cost' => $item['unit_cost'] ?? 0,
'total_value' => 0,
'location' => $item['location'] ?? null,
'arrival_date' => $meta['inboundDate'],
'expiry_date' => $item['expiryDate'] ?? null,
'origin_country' => $originCountry,
]
);
if ($inventory->trashed()) {
$inventory->restore();
}
}
$currentQty = $inventory->quantity;
$newQty = $currentQty + $item['quantity'];
$inventory->quantity = $newQty;
// 更新總價值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->saveQuietly();
// 寫入異動紀錄
$inventory->transactions()->create([
'type' => '手動入庫',
'quantity' => $item['quantity'],
'unit_cost' => $inventory->unit_cost,
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $meta['reason'] . (!empty($meta['notes']) ? ' - ' . $meta['notes'] : ''),
'actual_time' => $meta['inboundDate'],
'user_id' => auth()->id(),
]);
}
});
}
public function adjustInventory(Inventory $inventory, array $data): void
{
DB::transaction(function () use ($inventory, $data) {
$currentQty = (float) $inventory->quantity;
$newQty = (float) $data['quantity'];
$isAdjustment = isset($data['operation']);
$changeQty = 0;
if ($isAdjustment) {
switch ($data['operation']) {
case 'add':
$changeQty = (float) $data['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'subtract':
$changeQty = -(float) $data['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'set':
$changeQty = $newQty - $currentQty;
break;
}
} else {
$changeQty = $newQty - $currentQty;
}
if (isset($data['unit_cost'])) {
$inventory->unit_cost = $data['unit_cost'];
}
$inventory->quantity = $newQty;
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->saveQuietly();
$type = $data['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
$typeMapping = [
'manual_adjustment' => '手動調整庫存',
'adjustment' => '盤點調整',
'purchase_in' => '採購進貨',
'sales_out' => '銷售出庫',
'return_in' => '退貨入庫',
'return_out' => '退貨出庫',
'transfer_in' => '撥補入庫',
'transfer_out' => '撥補出庫',
];
$chineseType = $typeMapping[$type] ?? $type;
if (!$isAdjustment && !isset($data['type'])) {
$chineseType = '手動編輯';
}
$reason = $data['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
if (!empty($data['notes'])) {
$reason .= ' - ' . $data['notes'];
}
if (abs($changeQty) > 0.0001) {
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => $chineseType,
'quantity' => $changeQty,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $reason,
'actual_time' => now(),
'user_id' => auth()->id(),
]);
$transaction->saveQuietly();
}
});
}
}

View File

@@ -110,4 +110,72 @@ class ProductService implements ProductServiceInterface
{
return Product::whereIn('code', $codes)->get();
}
public function createProduct(array $data)
{
if (empty($data['code'])) {
$data['code'] = $this->generateRandomCode();
}
if (empty($data['barcode'])) {
$data['barcode'] = $this->generateRandomBarcode();
}
return Product::create($data);
}
public function updateProduct(Product $product, array $data)
{
if (empty($data['code'])) {
$data['code'] = $this->generateRandomCode();
}
if (empty($data['barcode'])) {
$data['barcode'] = $this->generateRandomBarcode();
}
$product->update($data);
return $product;
}
public function generateRandomCode()
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$code = '';
do {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $characters[rand(0, strlen($characters) - 1)];
}
} while (Product::where('code', $code)->exists());
return $code;
}
public function generateRandomBarcode()
{
$barcode = '';
do {
$barcode = '';
for ($i = 0; $i < 13; $i++) {
$barcode .= rand(0, 9);
}
} while (Product::where('barcode', $barcode)->exists());
return $barcode;
}
public function findByBarcodeOrCode(?string $barcode, ?string $code)
{
$product = null;
if (!empty($barcode)) {
$product = Product::where('barcode', $barcode)->first();
}
if (!$product && !empty($code)) {
$product = Product::where('code', $code)->first();
}
return $product;
}
}

View File

@@ -23,24 +23,77 @@ class StoreRequisitionService
/**
* 建立叫貨單(含明細)
*/
public function create(array $data, array $items, int $userId): StoreRequisition
public function create(array $data, array $items, int $userId, bool $submitImmediately = false): StoreRequisition
{
return DB::transaction(function () use ($data, $items, $userId) {
$requisition = StoreRequisition::create([
return DB::transaction(function () use ($data, $items, $userId, $submitImmediately) {
$requisition = new StoreRequisition([
'store_warehouse_id' => $data['store_warehouse_id'],
'status' => 'draft',
'status' => $submitImmediately ? 'pending' : 'draft',
'submitted_at' => $submitImmediately ? now() : null,
'remark' => $data['remark'] ?? null,
'created_by' => $userId,
]);
// 手動產生單號,因為 saveQuietly 會繞過模型事件
if (empty($requisition->doc_no)) {
$today = date('Ymd');
$prefix = 'SR-' . $today . '-';
$lastDoc = StoreRequisition::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc')
->first();
if ($lastDoc) {
$lastNumber = substr($lastDoc->doc_no, -2);
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
$requisition->doc_no = $prefix . $nextNumber;
}
// 靜默建立以抑制自動日誌
$requisition->saveQuietly();
$diff = ['added' => [], 'removed' => [], 'updated' => []];
foreach ($items as $item) {
$requisition->items()->create([
'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null,
]);
$product = \App\Modules\Inventory\Models\Product::find($item['product_id']);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity' => (float)$item['requested_qty'],
'remark' => $item['remark'] ?? null,
]
];
}
// 如果需直接提交,觸發通知
if ($submitImmediately) {
$this->notifyApprovers($requisition, 'submitted', $userId);
}
// 手動發送高品質日誌
activity()
->performedOn($requisition)
->causedBy($userId)
->event('created')
->withProperties([
'items_diff' => $diff,
'attributes' => [
'doc_no' => $requisition->doc_no,
'store_warehouse_id' => $requisition->store_warehouse_id,
'status' => $requisition->status,
'remark' => $requisition->remark,
'created_by' => $requisition->created_by,
'submitted_at' => $requisition->submitted_at,
]
])
->log('created');
return $requisition->load('items');
});
}
@@ -57,13 +110,74 @@ class StoreRequisitionService
}
return DB::transaction(function () use ($requisition, $data, $items) {
$requisition->update([
'store_warehouse_id' => $data['store_warehouse_id'],
'remark' => $data['remark'] ?? null,
'reject_reason' => null, // 清除駁回原因
]);
// 擷取舊狀態供日誌對照
$oldAttributes = [
'store_warehouse_id' => $requisition->store_warehouse_id,
'remark' => $requisition->remark,
];
// 重建明細
// 手動更新屬性
$requisition->store_warehouse_id = $data['store_warehouse_id'];
$requisition->remark = $data['remark'] ?? null;
$requisition->reject_reason = null; // 清除駁回原因
// 品項對比邏輯
$oldItems = $requisition->items()->with('product:id,name')->get();
$oldItemsMap = $oldItems->keyBy('product_id');
$newItemsMap = collect($items)->keyBy('product_id');
$diff = [
'added' => [],
'removed' => [],
'updated' => [],
];
// 1. 處理更新與新增
foreach ($items as $itemData) {
$productId = $itemData['product_id'];
$newQty = (float)$itemData['requested_qty'];
$newRemark = $itemData['remark'] ?? null;
if ($oldItemsMap->has($productId)) {
$oldItem = $oldItemsMap->get($productId);
if ((float)$oldItem->requested_qty !== $newQty || $oldItem->remark !== $newRemark) {
$diff['updated'][] = [
'product_name' => $oldItem->product?->name ?? '未知商品',
'old' => [
'quantity' => (float)$oldItem->requested_qty,
'remark' => $oldItem->remark,
],
'new' => [
'quantity' => $newQty,
'remark' => $newRemark,
]
];
}
$oldItemsMap->forget($productId);
} else {
$product = \App\Modules\Inventory\Models\Product::find($productId);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity' => $newQty,
'remark' => $newRemark,
]
];
}
}
// 2. 處理移除
foreach ($oldItemsMap as $productId => $oldItem) {
$diff['removed'][] = [
'product_name' => $oldItem->product?->name ?? '未知商品',
'old' => [
'quantity' => (float)$oldItem->requested_qty,
'remark' => $oldItem->remark,
]
];
}
// 儲存實際變動
$requisition->items()->delete();
foreach ($items as $item) {
$requisition->items()->create([
@@ -73,6 +187,32 @@ class StoreRequisitionService
]);
}
// 檢查是否有任何變動 (主表或明細)
$isDirty = $requisition->isDirty();
$hasItemsDiff = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
if ($isDirty || $hasItemsDiff) {
// 擷取新狀態
$newAttributes = [
'store_warehouse_id' => $requisition->store_warehouse_id,
'remark' => $requisition->remark,
];
// 靜默更新
$requisition->saveQuietly();
// 手動發送紀錄
activity()
->performedOn($requisition)
->event('updated')
->withProperties([
'items_diff' => $diff,
'attributes' => $newAttributes,
'old' => $oldAttributes
])
->log('updated');
}
return $requisition->load('items');
});
}
@@ -118,17 +258,89 @@ class StoreRequisitionService
}
return DB::transaction(function () use ($requisition, $data, $userId) {
// 更新核准數量
// 處理前端傳來的明細與批號資料
$processedItems = []; // 暫存處理後的明細,用於轉入調撥單
if (isset($data['items'])) {
$requisition->load('items.product');
$reqItemMap = $requisition->items->keyBy('id');
foreach ($data['items'] as $itemData) {
StoreRequisitionItem::where('id', $itemData['id'])
$reqItemId = $itemData['id'];
$reqItem = $reqItemMap->get($reqItemId);
$productName = $reqItem?->product?->name ?? '未知商品';
$totalApprovedQty = 0;
$batches = $itemData['batches'] ?? [];
// 如果有批號,根據批號展開。若有多個無批號(null)的批次(例如來自不同貨道),則將其數量加總
if (!empty($batches)) {
$batchGroups = [];
foreach ($batches as $batch) {
$qty = (float)($batch['qty'] ?? 0);
$bNum = $batch['batch_number'] ?? null;
$invId = $batch['inventory_id'] ?? null;
if ($qty > 0) {
if ($invId) {
$inventory = \App\Modules\Inventory\Models\Inventory::lockForUpdate()->find($invId);
if ($inventory) {
$available = max(0, $inventory->quantity - $inventory->reserved_quantity);
if ($qty > $available) {
$batchStr = $bNum ? "批號 {$bNum}" : "無批號";
throw ValidationException::withMessages([
'items' => "{$productName}」的 {$batchStr} 數量({$qty})不可大於可用庫存({$available})",
]);
}
}
}
$totalApprovedQty += $qty;
$batchKey = $bNum ?? '';
$batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty;
}
}
foreach ($batchGroups as $bNumKey => $qty) {
$processedItems[] = [
'req_item_id' => $reqItemId,
'batch_number' => $bNumKey === '' ? null : $bNumKey,
'quantity' => $qty,
];
}
} else {
// 無批號,傳統輸入
$qty = (float)($itemData['approved_qty'] ?? 0);
if ($qty > 0) {
$supplyWarehouseId = $requisition->supply_warehouse_id;
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
->where('product_id', $reqItem->product_id)
->selectRaw('SUM(quantity - reserved_quantity) as available')
->value('available') ?? 0;
if ($qty > $totalAvailable) {
throw ValidationException::withMessages([
'items' => "{$productName}」的數量({$qty})不可大於供貨倉可用總庫存({$totalAvailable})",
]);
}
$totalApprovedQty += $qty;
$processedItems[] = [
'req_item_id' => $reqItemId,
'batch_number' => null,
'quantity' => $qty,
];
}
}
// 更新叫貨單明細的核准數量總和
StoreRequisitionItem::where('id', $reqItemId)
->where('store_requisition_id', $requisition->id)
->update(['approved_qty' => $itemData['approved_qty']]);
->update(['approved_qty' => $totalApprovedQty]);
}
}
// 優先使用傳入的供貨倉庫,若無則從單據中取得
$supplyWarehouseId = $data['supply_warehouse_id'] ?? $requisition->supply_warehouse_id;
$supplyWarehouseId = $requisition->supply_warehouse_id;
if (!$supplyWarehouseId) {
throw ValidationException::withMessages([
@@ -152,18 +364,44 @@ class StoreRequisitionService
// 將核准的明細寫入調撥單
$requisition->load('items');
$transferItems = [];
foreach ($requisition->items as $item) {
$qty = $item->approved_qty ?? $item->requested_qty;
if ($qty > 0) {
// 建立 req_item_id 對應 product_id 的 lookup
$reqItemMap = $requisition->items->keyBy('id');
foreach ($processedItems as $pItem) {
$reqItem = $reqItemMap->get($pItem['req_item_id']);
if ($reqItem) {
$transferItems[] = [
'product_id' => $item->product_id,
'quantity' => $qty,
'product_id' => $reqItem->product_id,
'batch_number' => $pItem['batch_number'],
'quantity' => $pItem['quantity'],
];
}
}
if (!empty($transferItems)) {
$this->transferService->updateItems($transferOrder, $transferItems);
// 手動發送調撥單的「已建立」合併日誌,包含初始明細
activity()
->performedOn($transferOrder)
->causedBy($userId)
->event('created')
->withProperties(array_merge(
['items_diff' => $transferOrder->activityProperties['items_diff'] ?? []],
[
'attributes' => [
'doc_no' => $transferOrder->doc_no,
'from_warehouse_id' => $transferOrder->from_warehouse_id,
'to_warehouse_id' => $transferOrder->to_warehouse_id,
'transit_warehouse_id' => $transferOrder->transit_warehouse_id,
'remarks' => $transferOrder->remarks,
'status' => $transferOrder->status,
'created_by' => $transferOrder->created_by,
]
]
))
->log('created');
}
// 更新叫貨單狀態

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransaction;
use App\Modules\Inventory\Models\GoodsReceiptItem;
use App\Modules\Inventory\Models\GoodsReceipt;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Collection;
class TraceabilityService
{
public function __construct(
protected ProductionServiceInterface $productionService,
protected ProcurementServiceInterface $procurementService
) {}
/**
* 逆向溯源:從成品批號往前追溯用到的所有原料與廠商
*
* @param string $batchNumber 成品批號
* @return array 樹狀結構資料
*/
public function traceBackward(string $batchNumber): array
{
// 取得基本庫存資訊以作為根節點參考
$baseInventory = Inventory::with(['product', 'warehouse'])
->where('batch_number', $batchNumber)
->first();
// 定義根節點
$rootNode = [
'id' => 'batch_' . $batchNumber,
'type' => 'target_batch',
'label' => '查詢批號: ' . $batchNumber,
'batch_number' => $batchNumber,
'product_name' => $baseInventory?->product?->name,
'spec' => $baseInventory?->product?->spec,
'warehouse_name' => $baseInventory?->warehouse?->name,
'children' => []
];
// 1. 尋找這個批號是不是生產出來的成品 (Production Order Output)
// 透過 ProductionService 獲取,以落實模組解耦
$productionOrders = $this->productionService->getProductionOrdersByOutputBatch($batchNumber);
foreach ($productionOrders as $po) {
$poNode = [
'id' => 'po_' . $po->id,
'type' => 'production_order',
'label' => '生產工單: ' . $po->code,
'date' => $po->production_date instanceof \DateTimeInterface
? $po->production_date->format('Y-m-d')
: $po->production_date,
'quantity' => $po->output_quantity,
'children' => []
];
// 針對每一張工單,尋找它投料的原料批號
foreach ($po->items as $item) {
if (isset($item->inventory)) {
$materialNode = $this->buildMaterialBackwardNode($item->inventory, $item);
$poNode['children'][] = $materialNode;
}
}
$rootNode['children'][] = $poNode;
}
// 2. 如果這批號是直接採購進來的 (Goods Receipt)
// 或者是為了補足直接查詢原料批號的場景
$inventories = Inventory::with(['product', 'warehouse'])
->where('batch_number', $batchNumber)
->get();
foreach ($inventories as $inv) {
// 尋找進貨單
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
->where('batch_number', $batchNumber)
->where('product_id', $inv->product_id)
->get();
foreach ($grItems as $grItem) {
$gr = $grItem->goodsReceipt;
if ($gr) {
$grNode = [
'id' => 'gr_' . $gr->id . '_' . $inv->id,
'type' => 'goods_receipt',
'label' => '進貨單: ' . $gr->code,
'date' => $gr->received_date instanceof \DateTimeInterface
? $gr->received_date->format('Y-m-d')
: $gr->received_date,
'vendor_id' => $gr->vendor_id,
'quantity' => $grItem->quantity,
'product_name' => $grItem->product?->name,
'children' => []
];
// 避免重複加入
$isDuplicate = false;
foreach ($rootNode['children'] as $child) {
if ($child['id'] === $grNode['id']) {
$isDuplicate = true;
break;
}
}
if (!$isDuplicate) {
$rootNode['children'][] = $grNode;
}
}
}
}
// 補充廠商名稱 (跨模組)
$this->hydrateVendorNames($rootNode);
return $rootNode;
}
/**
* 建立原料的逆向溯源節點
*/
private function buildMaterialBackwardNode(Inventory $inventory, $poItem = null): array
{
$node = [
'id' => 'inv_' . $inventory->id,
'type' => 'material_batch',
'label' => '原料批號: ' . $inventory->batch_number,
'product_name' => $inventory->product?->name,
'spec' => $inventory->product?->spec,
'batch_number' => $inventory->batch_number,
'quantity' => $poItem ? $poItem->quantity_used : null,
'warehouse_name' => $inventory->warehouse?->name,
'children' => []
];
// 繼續往下追溯該原料是怎麼來的 (進貨單)
if ($inventory->batch_number) {
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
->where('batch_number', $inventory->batch_number)
->where('product_id', $inventory->product_id)
->get();
foreach ($grItems as $grItem) {
$gr = $grItem->goodsReceipt;
if ($gr) {
$node['children'][] = [
'id' => 'gr_' . $gr->id,
'type' => 'goods_receipt',
'label' => '進貨單: ' . $gr->code,
'date' => $gr->received_date instanceof \DateTimeInterface
? $gr->received_date->format('Y-m-d')
: $gr->received_date,
'vendor_id' => $gr->vendor_id,
'quantity' => $grItem->quantity,
'product_name' => $grItem->product?->name,
'children' => []
];
}
}
}
return $node;
}
/**
* 順向追蹤:從原料批號往後追查被用在哪些成品及去向
*
* @param string $batchNumber 原料批號
* @return array 樹狀結構資料
*/
public function traceForward(string $batchNumber): array
{
$baseInventory = Inventory::with(['product', 'warehouse'])
->where('batch_number', $batchNumber)
->first();
$rootNode = [
'id' => 'batch_' . $batchNumber,
'type' => 'source_batch',
'label' => '查詢批號: ' . $batchNumber,
'batch_number' => $batchNumber,
'product_name' => $baseInventory?->product?->name,
'spec' => $baseInventory?->product?->spec,
'warehouse_name' => $baseInventory?->warehouse?->name,
'children' => []
];
// 1. 尋找這個批號被哪些工單使用了
$inventories = Inventory::with(['product', 'warehouse'])->where('batch_number', $batchNumber)->get();
foreach ($inventories as $inv) {
// 透過 ProductionService 獲取,以落實模組解耦
$poItems = $this->productionService->getProductionOrderItemsByInventoryId($inv->id, ['productionOrder']);
foreach ($poItems as $item) {
$po = $item->productionOrder;
if ($po) {
$poNode = [
'id' => 'po_' . $po->id,
'type' => 'production_order',
'label' => '投入工單: ' . $po->code,
'date' => $po->production_date instanceof \DateTimeInterface
? $po->production_date->format('Y-m-d')
: $po->production_date,
'quantity' => $item->quantity_used,
'children' => []
];
// 該工單產出的成品批號
if ($po->output_batch_number) {
$outputInventory = Inventory::with(['product', 'warehouse'])
->where('batch_number', $po->output_batch_number)
->first();
$outputNode = [
'id' => 'output_batch_' . $po->output_batch_number,
'type' => 'target_batch',
'label' => '產出成品: ' . $po->output_batch_number,
'batch_number' => $po->output_batch_number,
'quantity' => $po->output_quantity,
'product_name' => $outputInventory?->product?->name,
'spec' => $outputInventory?->product?->spec,
'warehouse_name' => $outputInventory?->warehouse?->name,
'children' => []
];
// 追蹤成品的出庫紀錄 (銷貨、領料等)
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
->whereHas('inventory', function ($q) use ($po) {
$q->where('batch_number', $po->output_batch_number);
})
->where('quantity', '<', 0) // 出庫
->get();
foreach ($outTransactions as $txn) {
$refType = class_basename($txn->reference_type);
$outputNode['children'][] = [
'id' => 'txn_' . $txn->id,
'type' => 'outbound_transaction',
'label' => '出庫單據: ' . $refType . ' #' . $txn->reference_id,
'date' => $txn->actual_time,
'quantity' => abs($txn->quantity),
'product_name' => $txn->inventory?->product?->name,
'children' => []
];
}
$poNode['children'][] = $outputNode;
}
$rootNode['children'][] = $poNode;
}
}
}
// 2. 如果這個批號自己本身就有出庫紀錄 (不是被生產掉,而是直接被領走或賣掉)
foreach ($inventories as $inv) {
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
->where('inventory_id', $inv->id)
->where('quantity', '<', 0)
->get();
foreach ($outTransactions as $txn) {
// 如果是生產工單領料,上面已經處理過,這裡濾掉
if ($txn->reference_type && str_contains($txn->reference_type, 'ProductionOrder')) {
continue;
}
$refType = $txn->reference_type ? class_basename($txn->reference_type) : '未知';
$rootNode['children'][] = [
'id' => 'txn_direct_' . $txn->id,
'type' => 'outbound_transaction',
'label' => '直接出庫: ' . $refType . ' #' . $txn->reference_id,
'date' => $txn->actual_time,
'quantity' => abs($txn->quantity),
'product_name' => $txn->inventory?->product?->name,
'children' => []
];
}
}
return $rootNode;
}
/**
* 水和廠商名稱 (跨模組)
*/
private function hydrateVendorNames(array &$node): void
{
$vendorIds = [];
$this->collectVendorIds($node, $vendorIds);
if (empty($vendorIds)) return;
$vendors = $this->procurementService->getVendorsByIds(array_unique($vendorIds))->keyBy('id');
$this->applyVendorNames($node, $vendors);
}
private function collectVendorIds(array $node, array &$ids): void
{
if (isset($node['vendor_id'])) {
$ids[] = $node['vendor_id'];
}
if (!empty($node['children'])) {
foreach ($node['children'] as $child) {
$this->collectVendorIds($child, $ids);
}
}
}
private function applyVendorNames(array &$node, Collection $vendors): void
{
if (isset($node['vendor_id']) && $vendors->has($node['vendor_id'])) {
$vendor = $vendors->get($node['vendor_id']);
$node['label'] .= ' (廠商: ' . $vendor->name . ')';
}
if (!empty($node['children'])) {
foreach ($node['children'] as &$child) {
$this->applyVendorNames($child, $vendors);
}
}
}
}

View File

@@ -9,8 +9,17 @@ use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
class TransferService
{
protected InventoryServiceInterface $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 建立調撥單草稿
*/
@@ -24,7 +33,7 @@ class TransferService
}
}
return InventoryTransferOrder::create([
$order = new InventoryTransferOrder([
'from_warehouse_id' => $fromWarehouseId,
'to_warehouse_id' => $toWarehouseId,
'transit_warehouse_id' => $transitWarehouseId,
@@ -32,6 +41,26 @@ class TransferService
'remarks' => $remarks,
'created_by' => $userId,
]);
// 手動觸發單號產生邏輯,因為 saveQuietly 繞過了 Model Events
if (empty($order->doc_no)) {
$today = date('Ymd');
$prefix = 'TRF-' . $today . '-';
$lastDoc = InventoryTransferOrder::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc')
->first();
if ($lastDoc) {
$lastNumber = substr($lastDoc->doc_no, -2);
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
$order->doc_no = $prefix . $nextNumber;
}
$order->saveQuietly();
return $order;
}
/**
@@ -45,6 +74,17 @@ class TransferService
return [$key => $item];
});
// 釋放舊明細的預扣庫存
foreach ($order->items as $item) {
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
if ($inv) {
$inv->releaseReservedQuantity($item->quantity);
}
}
$diff = [
'added' => [],
'removed' => [],
@@ -67,6 +107,21 @@ class TransferService
]);
$item->load('product');
// 增加新明細的預扣庫存
$inv = Inventory::firstOrCreate(
[
'warehouse_id' => $order->from_warehouse_id,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
],
[
'quantity' => 0,
'unit_cost' => 0,
'total_value' => 0,
]
);
$inv->reserveQuantity($item->quantity);
if ($oldItemsMap->has($key)) {
$oldItem = $oldItemsMap->get($key);
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
@@ -75,6 +130,7 @@ class TransferService
$diff['updated'][] = [
'product_name' => $item->product->name,
'unit_name' => $item->product->baseUnit?->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'position' => $oldItem->position,
@@ -88,12 +144,9 @@ class TransferService
];
}
} else {
$diff['updated'][] = [
$diff['added'][] = [
'product_name' => $item->product->name,
'old' => [
'quantity' => 0,
'notes' => null,
],
'unit_name' => $item->product->baseUnit?->name,
'new' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
@@ -106,6 +159,7 @@ class TransferService
if (!in_array($key, $newItemsKeys)) {
$diff['removed'][] = [
'product_name' => $oldItem->product->name,
'unit_name' => $oldItem->product->baseUnit?->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'notes' => $oldItem->notes,
@@ -143,6 +197,8 @@ class TransferService
$outType = '調撥出庫';
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
$itemsDiff = [];
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
@@ -160,67 +216,65 @@ class TransferService
]);
}
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $item->quantity;
$item->update(['snapshot_quantity' => $oldSourceQty]);
$sourceBefore = (float) $sourceInventory->quantity;
$sourceInventory->quantity = $newSourceQty;
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
$sourceInventory->save();
// 釋放草稿階段預扣的庫存
$sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity);
$sourceInventory->saveQuietly();
$sourceInventory->transactions()->create([
'type' => $outType,
'quantity' => -$item->quantity,
'unit_cost' => $sourceInventory->unit_cost,
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "調撥單 {$order->doc_no}{$targetWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
$item->update(['snapshot_quantity' => $sourceBefore]);
// 委託 InventoryService 處理扣庫與 Transaction
$this->inventoryService->decreaseInventoryQuantity(
$sourceInventory->id,
$item->quantity,
"調撥單 {$order->doc_no}{$targetWarehouse->name}",
InventoryTransferOrder::class,
$order->id
);
$sourceAfter = $sourceBefore - (float) $item->quantity;
// 2. 處理目的倉/在途倉 (增加)
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $targetWarehouseId,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $hasTransit ? null : ($item->position ?? null),
],
[
'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost,
'total_value' => 0,
'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country,
]
);
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
// 獲取目的倉異動前的庫存數(若無則為 0
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $item->quantity;
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
$targetInventory->transactions()->create([
'type' => $inType,
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $targetWarehouseId,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'unit_cost' => $sourceInventory->unit_cost,
'batch_number' => $item->batch_number,
'expiry_date' => $sourceInventory->expiry_date,
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
'reference_type' => InventoryTransferOrder::class,
'reference_id' => $order->id,
'location' => $hasTransit ? null : ($item->position ?? null),
'origin_country' => $sourceInventory->origin_country,
'quality_status' => $sourceInventory->quality_status,
]);
$targetAfter = $targetBefore + (float) $item->quantity;
// 記錄異動明細供整合日誌使用
$itemsDiff[] = [
'product_name' => $item->product->name,
'batch_number' => $item->batch_number,
'quantity' => (float)$item->quantity,
'source_warehouse' => $fromWarehouse->name,
'source_before' => $sourceBefore,
'source_after' => $sourceAfter,
'target_warehouse' => $targetWarehouse->name,
'target_before' => $targetBefore,
'target_after' => $targetAfter,
];
}
$oldStatus = $order->status;
if ($hasTransit) {
$order->status = 'dispatched';
$order->dispatched_at = now();
@@ -230,7 +284,27 @@ class TransferService
$order->posted_at = now();
$order->posted_by = $userId;
}
$order->save();
$order->saveQuietly();
// 手動觸發單一合併日誌
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'items_diff' => $itemsDiff,
'attributes' => [
'status' => $order->status,
'dispatched_at' => $order->dispatched_at ? $order->dispatched_at->format('Y-m-d H:i:s') : null,
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i:s') : null,
'dispatched_by' => $order->dispatched_by,
'posted_by' => $order->posted_by,
],
'old' => [
'status' => $oldStatus,
]
])
->log($order->status == 'completed' ? 'posted' : 'dispatched');
});
}
@@ -254,6 +328,8 @@ class TransferService
$transitWarehouse = $order->transitWarehouse;
$toWarehouse = $order->toWarehouse;
$itemsDiff = [];
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
@@ -270,71 +346,83 @@ class TransferService
]);
}
$oldTransitQty = $transitInventory->quantity;
$newTransitQty = $oldTransitQty - $item->quantity;
$transitBefore = (float) $transitInventory->quantity;
$transitInventory->quantity = $newTransitQty;
$transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost;
$transitInventory->save();
$transitInventory->transactions()->create([
'type' => '在途出庫',
'quantity' => -$item->quantity,
'unit_cost' => $transitInventory->unit_cost,
'balance_before' => $oldTransitQty,
'balance_after' => $newTransitQty,
'reason' => "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 委託 InventoryService 處理扣庫與 Transaction
$this->inventoryService->decreaseInventoryQuantity(
$transitInventory->id,
$item->quantity,
"調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
InventoryTransferOrder::class,
$order->id
);
$transitAfter = $transitBefore - (float) $item->quantity;
// 2. 目的倉增加
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $item->position,
],
[
'quantity' => 0,
'unit_cost' => $transitInventory->unit_cost,
'total_value' => 0,
'expiry_date' => $transitInventory->expiry_date,
'quality_status' => $transitInventory->quality_status,
'origin_country' => $transitInventory->origin_country,
]
);
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $transitInventory->unit_cost;
}
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $item->quantity;
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
$targetInventory->transactions()->create([
'type' => '調撥入庫',
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'unit_cost' => $transitInventory->unit_cost,
'batch_number' => $item->batch_number,
'expiry_date' => $transitInventory->expiry_date,
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
'reference_type' => InventoryTransferOrder::class,
'reference_id' => $order->id,
'location' => $item->position,
'origin_country' => $transitInventory->origin_country,
'quality_status' => $transitInventory->quality_status,
]);
$targetAfter = $targetBefore + (float) $item->quantity;
$itemsDiff[] = [
'product_name' => $item->product->name,
'batch_number' => $item->batch_number,
'quantity' => (float)$item->quantity,
'source_warehouse' => $transitWarehouse->name,
'source_before' => $transitBefore,
'source_after' => $transitAfter,
'target_warehouse' => $toWarehouse->name,
'target_before' => $targetBefore,
'target_after' => $targetAfter,
];
}
$oldStatus = $order->status;
$order->status = 'completed';
$order->posted_at = now();
$order->posted_by = $userId;
$order->received_at = now();
$order->received_by = $userId;
$order->save();
$order->saveQuietly();
// 手動觸發單一合併日誌
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'items_diff' => $itemsDiff,
'attributes' => [
'status' => 'completed',
'posted_at' => $order->posted_at->format('Y-m-d H:i:s'),
'received_at' => $order->received_at->format('Y-m-d H:i:s'),
'posted_by' => $order->posted_by,
'received_by' => $order->received_by,
],
'old' => [
'status' => $oldStatus,
]
])
->log('received');
});
}
@@ -346,9 +434,36 @@ class TransferService
if ($order->status !== 'draft') {
throw new \Exception('只能作廢草稿狀態的單據');
}
$order->update([
'status' => 'voided',
'updated_by' => $userId
]);
DB::transaction(function () use ($order, $userId) {
foreach ($order->items as $item) {
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
if ($inv) {
$inv->releaseReservedQuantity($item->quantity);
}
}
$oldStatus = $order->status;
$order->status = 'voided';
$order->updated_by = $userId;
$order->saveQuietly();
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => [
'status' => 'voided',
],
'old' => [
'status' => $oldStatus,
]
])
->log('voided');
});
}
}

View File

@@ -12,8 +12,9 @@ class TurnoverService
/**
* Get inventory turnover analysis data
*/
public function getAnalysisData(array $filters, int $perPage = 20)
public function getAnalysisData(array $filters, ?int $perPage = null)
{
$perPage = $perPage ?? \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$warehouseId = $filters['warehouse_id'] ?? null;
$categoryId = $filters['category_id'] ?? null;
$search = $filters['search'] ?? null;
@@ -60,7 +61,8 @@ class TurnoverService
// Given potentially large data, subquery per row might be slow, but for pagination it's okay-ish.
// Better approach: Join with a subquery of aggregated transactions.
$thirtyDaysAgo = Carbon::now()->subDays(30);
$analysisDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.analysis_period_days', 30);
$thirtyDaysAgo = Carbon::now()->subDays($analysisDays);
// Subquery for 30-day sales
$salesSubquery = InventoryTransaction::query()
@@ -111,7 +113,7 @@ class TurnoverService
// Turnover Days Calculation in SQL: (stock / (sales_30d / 30)) => (stock * 30) / sales_30d
// Handle division by zero: if sales_30d is 0, turnover is 'Inf' (or very high number like 9999)
$turnoverDaysSql = "CASE WHEN COALESCE(sales_30d.sales_qty_30d, 0) > 0
THEN (COALESCE(SUM(inventories.quantity), 0) * 30) / sales_30d.sales_qty_30d
THEN (COALESCE(SUM(inventories.quantity), 0) * $analysisDays) / sales_30d.sales_qty_30d
ELSE 9999 END";
$query->addSelect(DB::raw("$turnoverDaysSql as turnover_days"));
@@ -125,7 +127,8 @@ class TurnoverService
// For dead stock, definitive IS stock > 0.
if ($statusFilter === 'dead') {
$ninetyDaysAgo = Carbon::now()->subDays(90);
$deadStockDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.dead_stock_days', 90);
$ninetyDaysAgo = Carbon::now()->subDays($deadStockDays);
$query->havingRaw("current_stock > 0 AND (last_sale_date < ? OR last_sale_date IS NULL)", [$ninetyDaysAgo]);
}
@@ -146,10 +149,13 @@ class TurnoverService
$lastSale = $item->last_sale_date ? Carbon::parse($item->last_sale_date) : null;
$daysSinceSale = $lastSale ? $lastSale->diffInDays(Carbon::now()) : 9999;
if ($item->current_stock > 0 && $daysSinceSale > 90) {
$deadStockDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.dead_stock_days', 90);
$slowMovingDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.slow_moving_days', 60);
if ($item->current_stock > 0 && $daysSinceSale > $deadStockDays) {
$item->status = 'dead'; // 滯銷
$item->status_label = '滯銷';
} elseif ($item->current_stock > 0 && $item->turnover_days > 60) {
} elseif ($item->current_stock > 0 && $item->turnover_days > $slowMovingDays) {
$item->status = 'slow'; // 週轉慢
$item->status_label = '週轉慢';
} elseif ($item->current_stock == 0) {
@@ -187,8 +193,8 @@ class TurnoverService
// 2. Dead Stock Value (No sale in 90 days)
// Need last sale date for each product-location or just product?
// Assuming dead stock is product-level logic for simplicity.
$ninetyDaysAgo = Carbon::now()->subDays(90);
$deadStockDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.dead_stock_days', 90);
$ninetyDaysAgo = Carbon::now()->subDays($deadStockDays);
// Get IDs of products sold in last 90 days
$soldProductIds = InventoryTransaction::query()
@@ -225,17 +231,17 @@ class TurnoverService
// Simplified: (Total Stock / Total Sales 30d) * 30
$totalStock = (clone $buildInvQuery())->sum('inventories.quantity');
$totalSales30d = DB::table('inventory_transactions')
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->join('products', 'inventories.product_id', '=', 'products.id')
->where('inventory_transactions.type', '出庫')
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays(30))
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
->sum(DB::raw('ABS(inventory_transactions.quantity)'));
$avgTurnoverDays = $totalSales30d > 0 ? ($totalStock * 30) / $totalSales30d : 0;
$analysisDays = \App\Modules\Core\Models\SystemSetting::getVal('turnover.analysis_period_days', 30);
$totalSales30d = DB::table('inventory_transactions')
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->join('products', 'inventories.product_id', '=', 'products.id')
->where('inventory_transactions.type', '出庫')
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
->sum(DB::raw('ABS(inventory_transactions.quantity)'));
$avgTurnoverDays = $totalSales30d > 0 ? ($totalStock * $analysisDays) / $totalSales30d : 0;
return [
'total_stock_value' => $totalValue,

View File

@@ -104,4 +104,12 @@ interface ProcurementServiceInterface
* 移除供貨商品關聯
*/
public function detachProductFromVendor(int $vendorId, int $productId): void;
/**
* 整批同步供貨商品
*
* @param int $vendorId
* @param array $productsData Format: [['product_id' => 1, 'last_price' => 100], ...]
*/
public function syncVendorProducts(int $vendorId, array $productsData): void;
}

View File

@@ -66,7 +66,11 @@ class PurchaseOrderController extends Controller
$query->orderBy($sortField, $sortDirection);
}
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$orders = $query->paginate($perPage)->withQueryString();
// 2. 手動注入倉庫與使用者資料
@@ -185,71 +189,123 @@ class PurchaseOrderController extends Controller
]);
try {
DB::beginTransaction();
// 使用 Cache Lock 防止併發時產生重複單號
$lock = \Illuminate\Support\Facades\Cache::lock('po_code_generation', 10);
// 生成單號PO-YYYYMMDD-01
$today = now()->format('Ymd');
$prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 2 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
$sequence = '01';
}
$code = $prefix . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
if (!$lock->get()) {
return back()->withErrors(['error' => '系統忙碌中,請稍後再試']);
}
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
try {
DB::beginTransaction();
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
}
// 生成單號PO-YYYYMMDD-01
$today = now()->format('Ymd');
$prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->orderBy('code', 'desc')
->first();
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'order_date' => $validated['order_date'], // 新增
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
if ($lastOrder) {
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
$sequence = '01';
}
$code = $prefix . $sequence;
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
}
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = $this->coreService->ensureSystemUserExists();
$userId = $user->id;
}
// 靜默建立以抑制自動日誌(後續手動發送含品項明細的日誌)
$order = new PurchaseOrder([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'order_date' => $validated['order_date'],
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
}
$order->saveQuietly();
DB::commit();
// 建立品項並收集 items_diff
$diff = ['added' => [], 'removed' => [], 'updated' => []];
$productIds = collect($validated['items'])->pluck('productId')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
]);
$product = $products->get($item['productId']);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity' => (float)$item['quantity'],
'subtotal' => (float)$item['subtotal'],
]
];
}
// 手動發送高品質日誌(包含品項明細)
activity()
->performedOn($order)
->causedBy($userId)
->event('created')
->withProperties([
'items_diff' => $diff,
'attributes' => [
'po_number' => $order->code,
'vendor_id' => $order->vendor_id,
'warehouse_id' => $order->warehouse_id,
'user_id' => $order->user_id,
'status' => $order->status,
'order_date' => $order->order_date,
'expected_delivery_date' => $order->expected_delivery_date,
'total_amount' => $order->total_amount,
'tax_amount' => $order->tax_amount,
'grand_total' => $order->grand_total,
'remark' => $order->remark,
'invoice_number' => $order->invoice_number,
'invoice_date' => $order->invoice_date,
'invoice_amount' => $order->invoice_amount,
]
])
->log('created');
DB::commit();
} finally {
$lock->release();
}
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
@@ -605,8 +661,6 @@ class PurchaseOrderController extends Controller
'snapshot' => [
'po_number' => $order->code,
'vendor_name' => $order->vendor?->name,
'warehouse_name' => $order->warehouse?->name,
'user_name' => $order->user?->name,
]
])
->log('updated');
@@ -659,8 +713,6 @@ class PurchaseOrderController extends Controller
'snapshot' => [
'po_number' => $order->code,
'vendor_name' => $order->vendor?->name,
'warehouse_name' => $order->warehouse?->name,
'user_name' => $order->user?->name,
]
])
->log('deleted');

View File

@@ -0,0 +1,251 @@
<?php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\PurchaseReturn;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Procurement\Services\PurchaseReturnService;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class PurchaseReturnController extends Controller
{
public function __construct(
protected PurchaseReturnService $purchaseReturnService,
protected InventoryServiceInterface $inventoryService
) {}
public function index(Request $request)
{
$query = PurchaseReturn::with(['vendor', 'user'])
->orderBy('id', 'desc');
if ($request->filled('search')) {
$search = $request->search;
$query->where('code', 'like', "%{$search}%")
->orWhereHas('vendor', function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
$perPage = $request->input('per_page', 15);
$purchaseReturns = $query->paginate($perPage)->withQueryString();
return Inertia::render('PurchaseReturn/Index', [
'purchaseReturns' => $purchaseReturns,
'filters' => $request->only(['search', 'status', 'per_page']),
]);
}
public function create()
{
// 取得可用的倉庫與廠商資料供前端選單使用
$warehouses = $this->inventoryService->getAllWarehouses();
$vendors = Vendor::all();
// 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致)
$vendorIds = $vendors->pluck('id')->toArray();
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
$productIds = $pivots->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$vendors = $vendors->map(function ($vendor) use ($pivots, $products) {
$vendorProductPivots = $pivots->where('vendor_id', $vendor->id);
$commonProducts = $vendorProductPivots->map(function($pivot) use ($products) {
$product = $products->get($pivot->product_id);
if (!$product) return null;
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'lastPrice' => (float) $pivot->last_price,
];
})->filter()->values();
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $commonProducts
];
});
return Inertia::render('PurchaseReturn/Create', [
'warehouses' => $warehouses,
'vendors' => $vendors,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|integer', // 透過 interface 無法直接 exists
'return_date' => 'required|date',
'remarks' => 'nullable|string',
'tax_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer',
'items.*.quantity_returned' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.batch_number' => 'nullable|string',
]);
try {
$pr = $this->purchaseReturnService->store($validated);
return redirect()->route('procurement.purchase-returns.show', $pr->id)
->with('flash', ['success' => '退貨單草稿建立成功']);
} catch (\Exception $e) {
return back()->with('flash', ['error' => $e->getMessage()]);
}
}
public function show(PurchaseReturn $purchaseReturn)
{
$purchaseReturn->load(['vendor', 'user', 'items']);
// 取出 product name (依賴反轉)
$productIds = $purchaseReturn->items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 取出 warehouse name
$warehouse = $this->inventoryService->getWarehouse($purchaseReturn->warehouse_id);
$purchaseReturn->warehouse_name = $warehouse ? $warehouse->name : '未知倉庫';
$purchaseReturn->items->transform(function($item) use ($products) {
$item->product_name = $products->get($item->product_id)->name ?? '未知商品';
$item->product_code = $products->get($item->product_id)->code ?? '';
return $item;
});
// 整理歷史紀錄
$activities = \Spatie\Activitylog\Models\Activity::where('subject_type', PurchaseReturn::class)
->where('subject_id', $purchaseReturn->id)
->orderBy('created_at', 'desc')
->get();
return Inertia::render('PurchaseReturn/Show', [
'purchaseReturn' => $purchaseReturn,
'activities' => $activities,
]);
}
public function edit(PurchaseReturn $purchaseReturn)
{
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
return redirect()->route('procurement.purchase-returns.show', $purchaseReturn->id)
->with('flash', ['error' => '只有草稿狀態的退貨單能編輯']);
}
$purchaseReturn->load(['items']);
$productIds = $purchaseReturn->items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$purchaseReturn->items->transform(function($item) use ($products) {
$product = $products->get($item->product_id);
$item->product = $product;
return $item;
});
$warehouses = $this->inventoryService->getAllWarehouses();
$vendors = Vendor::all();
// 手動注入:獲取廠商商品 (與 create 邏輯一致)
$vendorIds = $vendors->pluck('id')->toArray();
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
$allProductIds = $pivots->pluck('product_id')->unique()->toArray();
$allProducts = $this->inventoryService->getProductsByIds($allProductIds)->keyBy('id');
$vendors = $vendors->map(function ($vendor) use ($pivots, $allProducts) {
$vendorProductPivots = $pivots->where('vendor_id', $vendor->id);
$commonProducts = $vendorProductPivots->map(function($pivot) use ($allProducts) {
$product = $allProducts->get($pivot->product_id);
if (!$product) return null;
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'lastPrice' => (float) $pivot->last_price,
];
})->filter()->values();
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $commonProducts
];
});
return Inertia::render('PurchaseReturn/Edit', [
'purchaseReturn' => $purchaseReturn,
'warehouses' => $warehouses,
'vendors' => $vendors,
]);
}
public function update(Request $request, PurchaseReturn $purchaseReturn)
{
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|integer',
'return_date' => 'required|date',
'remarks' => 'nullable|string',
'tax_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer',
'items.*.quantity_returned' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.batch_number' => 'nullable|string',
]);
try {
$this->purchaseReturnService->update($purchaseReturn, $validated);
return redirect()->route('procurement.purchase-returns.show', $purchaseReturn->id)
->with('flash', ['success' => '退貨單已更新']);
} catch (\Exception $e) {
return back()->with('flash', ['error' => $e->getMessage()]);
}
}
public function submit(PurchaseReturn $purchaseReturn)
{
try {
$this->purchaseReturnService->submit($purchaseReturn);
return back()->with('flash', ['success' => '退貨單已確認完成,庫存已成功扣減。']);
} catch (\Exception $e) {
return back()->with('flash', ['error' => '退貨失敗: ' . $e->getMessage()]);
}
}
public function cancel(PurchaseReturn $purchaseReturn)
{
try {
$this->purchaseReturnService->cancel($purchaseReturn);
return back()->with('flash', ['success' => '退貨單已取消']);
} catch (\Exception $e) {
return back()->with('flash', ['error' => $e->getMessage()]);
}
}
public function destroy(PurchaseReturn $purchaseReturn)
{
try {
$this->purchaseReturnService->delete($purchaseReturn);
return redirect()->route('procurement.purchase-returns.index')
->with('flash', ['success' => '退貨單草稿已刪除']);
} catch (\Exception $e) {
return back()->with('flash', ['error' => $e->getMessage()]);
}
}
}

View File

@@ -48,7 +48,11 @@ class ShippingOrderController extends Controller
$query->where('status', $request->status);
}
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$orders = $query->orderBy('id', 'desc')->paginate($perPage)->withQueryString();
// 水和倉庫與使用者

View File

@@ -44,7 +44,11 @@ class VendorController extends Controller
$sortDirection = 'desc';
}
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$vendors = $query->orderBy($sortField, $sortDirection)
->paginate($perPage)

View File

@@ -133,4 +133,35 @@ class VendorProductController extends Controller
return redirect()->back()->with('success', '供貨商品已移除');
}
/**
* 整批同步供貨商品
*/
public function sync(Request $request, Vendor $vendor)
{
$validated = $request->validate([
'products' => 'present|array',
'products.*.product_id' => 'required|exists:products,id',
'products.*.last_price' => 'nullable|numeric|min:0',
]);
$this->procurementService->syncVendorProducts($vendor->id, $validated['products']);
activity()
->performedOn($vendor)
->withProperties([
'attributes' => [
'products_count' => count($validated['products']),
],
'sub_subject' => '供貨商品',
'snapshot' => [
'name' => "{$vendor->name} 的供貨清單",
'vendor_name' => $vendor->name,
]
])
->event('updated')
->log('整批更新供貨商品');
return redirect()->back()->with('success', '供貨商品已更新');
}
}

View File

@@ -24,6 +24,9 @@ class PurchaseOrder extends Model
'tax_amount',
'grand_total',
'remark',
'invoice_number',
'invoice_date',
'invoice_amount',
];
protected $casts = [
@@ -42,19 +45,52 @@ class PurchaseOrder extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$snapshot = $activity->properties['snapshot'] ?? [];
$snapshot['po_number'] = $this->code;
if ($this->vendor) {
$snapshot['vendor_name'] = $this->vendor->name;
}
// Warehouse relation removed in Strict Mode. Snapshot should be set via manual hydration if needed,
// or during the procurement process where warehouse_id is known.
// 🚩 核心:轉換為陣列以避免 Indirect modification error
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$activity->properties = $activity->properties->merge([
'snapshot' => $snapshot
]);
// 1. Snapshot 快照
$snapshot = $properties['snapshot'] ?? [];
$snapshot['po_number'] = $this->code;
$snapshot['vendor_name'] = $this->vendor?->name;
// 倉庫名稱需透過服務取得(跨模組),若已在 snapshot 中則保留
if (!isset($snapshot['warehouse_name']) && $this->warehouse_id) {
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)->getWarehouse($this->warehouse_id);
$snapshot['warehouse_name'] = $warehouse?->name ?? null;
}
$properties['snapshot'] = $snapshot;
// 2. 名稱解析:自動將 attributes 與 old 中的 ID 換成人名/物名
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 使用者 ID 轉換
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name ?? $data[$f];
}
}
// 廠商 ID 轉換
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
$data['vendor_id'] = Vendor::find($data['vendor_id'])?->name ?? $data['vendor_id'];
}
// 倉庫 ID 轉換(跨模組,透過服務)
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)->getWarehouse($data['warehouse_id']);
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
// 3. 合併 activityProperties (手動傳入的 items_diff 等)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}
$activity->properties = $properties;
}
public function vendor(): \Illuminate\Database\Eloquent\Relations\BelongsTo

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PurchaseReturn extends Model
{
use HasFactory, SoftDeletes;
use \Spatie\Activitylog\Traits\LogsActivity;
public const STATUS_DRAFT = 'draft';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'code',
'vendor_id',
'warehouse_id',
'user_id',
'return_date',
'status',
'total_amount',
'tax_amount',
'grand_total',
'remarks',
];
protected $casts = [
'return_date' => 'date',
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'grand_total' => 'decimal:2',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
// 活動紀錄名稱解析 (依 activity-logging.md)
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['return_no'] = $this->code;
$snapshot['warehouse_name'] = $this->warehouse?->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
$data['vendor_id'] = Vendor::find($data['vendor_id'])?->name;
}
// Strict Mode: Warehouse relation may not be available directly here if it's across modules,
// but we might need it for display.
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
public function items()
{
return $this->hasMany(PurchaseReturnItem::class);
}
public function vendor()
{
return $this->belongsTo(Vendor::class);
}
public function user()
{
return $this->belongsTo(\App\Modules\Core\Models\User::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PurchaseReturnItem extends Model
{
use HasFactory;
protected $fillable = [
'purchase_return_id',
'product_id',
'quantity_returned',
'unit_price',
'total_amount',
'batch_number',
];
protected $casts = [
'quantity_returned' => 'decimal:2',
'unit_price' => 'decimal:2',
'total_amount' => 'decimal:2',
];
public function purchaseReturn()
{
return $this->belongsTo(PurchaseReturn::class);
}
// Strict Mode: product relation via ID only for external module.
}

View File

@@ -16,6 +16,7 @@ Route::middleware('auth')->group(function () {
// 供貨商品相關路由
Route::post('/vendors/{vendor}/products', [VendorProductController::class, 'store'])->middleware('permission:vendors.edit')->name('vendors.products.store');
Route::put('/vendors/{vendor}/products/sync', [VendorProductController::class, 'sync'])->middleware('permission:vendors.edit')->name('vendors.products.sync');
Route::put('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.products.update');
Route::delete('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'destroy'])->middleware('permission:vendors.edit')->name('vendors.products.destroy');
});
@@ -36,6 +37,27 @@ Route::middleware('auth')->group(function () {
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
});
// 採購退貨單管理
Route::middleware('permission:purchase_returns.view')->group(function () {
Route::get('/purchase-returns', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'index'])->name('purchase-returns.index');
Route::middleware('permission:purchase_returns.create')->group(function () {
Route::get('/purchase-returns/create', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'create'])->name('purchase-returns.create');
Route::post('/purchase-returns', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'store'])->name('purchase-returns.store');
});
Route::get('/purchase-returns/{purchaseReturn}', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'show'])->name('purchase-returns.show');
Route::middleware('permission:purchase_returns.edit')->group(function () {
Route::get('/purchase-returns/{purchaseReturn}/edit', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'edit'])->name('purchase-returns.edit');
Route::put('/purchase-returns/{purchaseReturn}', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'update'])->name('purchase-returns.update');
Route::post('/purchase-returns/{purchaseReturn}/submit', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'submit'])->name('purchase-returns.submit');
Route::post('/purchase-returns/{purchaseReturn}/cancel', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'cancel'])->name('purchase-returns.cancel');
});
Route::delete('/purchase-returns/{purchaseReturn}', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'destroy'])->middleware('permission:purchase_returns.delete')->name('purchase-returns.destroy');
});
// 出貨單管理 (Delivery Notes)
Route::middleware('permission:delivery_notes.view')->group(function () {
Route::get('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'index'])->name('delivery-notes.index');

View File

@@ -147,4 +147,48 @@ class ProcurementService implements ProcurementServiceInterface
->where('product_id', $productId)
->delete();
}
public function syncVendorProducts(int $vendorId, array $productsData): void
{
\Illuminate\Support\Facades\DB::transaction(function () use ($vendorId, $productsData) {
$existingPivots = \Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->get();
$existingProductIds = $existingPivots->pluck('product_id')->toArray();
$newProductIds = array_column($productsData, 'product_id');
$toDelete = array_diff($existingProductIds, $newProductIds);
if (!empty($toDelete)) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->whereIn('product_id', $toDelete)
->delete();
}
foreach ($productsData as $data) {
$exists = in_array($data['product_id'], $existingProductIds);
if ($exists) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->where('product_id', $data['product_id'])
->update([
'last_price' => $data['last_price'] ?? null,
'updated_at' => now(),
]);
} else {
\Illuminate\Support\Facades\DB::table('product_vendor')
->insert([
'vendor_id' => $vendorId,
'product_id' => $data['product_id'],
'last_price' => $data['last_price'] ?? null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
});
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Modules\Procurement\Services;
use App\Modules\Procurement\Models\PurchaseReturn;
use App\Modules\Procurement\Models\PurchaseReturnItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
class PurchaseReturnService
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
// 依賴反轉,透過介面呼叫 Inventory
$this->inventoryService = $inventoryService;
}
/**
* 建立退貨單 (草稿)
*/
public function store(array $data)
{
return DB::transaction(function () use ($data) {
$data['code'] = $this->generateCode($data['return_date']);
$data['user_id'] = auth()->id();
$data['status'] = PurchaseReturn::STATUS_DRAFT;
$totalAmount = 0;
$purchaseReturn = PurchaseReturn::create($data);
foreach ($data['items'] as $itemData) {
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
$totalAmount += $amount;
$prItem = new PurchaseReturnItem([
'product_id' => $itemData['product_id'],
'quantity_returned' => $itemData['quantity_returned'],
'unit_price' => $itemData['unit_price'],
'total_amount' => $amount,
'batch_number' => $itemData['batch_number'] ?? null,
]);
$purchaseReturn->items()->save($prItem);
}
// 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount)
$taxAmount = $data['tax_amount'] ?? 0;
$purchaseReturn->update([
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $totalAmount + $taxAmount,
]);
return $purchaseReturn;
});
}
/**
* 更新退貨單 (限草稿)
*/
public function update(PurchaseReturn $purchaseReturn, array $data)
{
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
throw new Exception('只有草稿狀態的退回單可以修改。');
}
return DB::transaction(function () use ($purchaseReturn, $data) {
$updateData = [
'vendor_id' => $data['vendor_id'] ?? $purchaseReturn->vendor_id,
'warehouse_id' => $data['warehouse_id'] ?? $purchaseReturn->warehouse_id,
'return_date' => $data['return_date'] ?? $purchaseReturn->return_date,
'remarks' => $data['remarks'] ?? $purchaseReturn->remarks,
];
if (isset($data['tax_amount'])) {
$updateData['tax_amount'] = $data['tax_amount'];
}
$purchaseReturn->update($updateData);
if (isset($data['items'])) {
$purchaseReturn->items()->delete();
$totalAmount = 0;
foreach ($data['items'] as $itemData) {
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
$totalAmount += $amount;
$prItem = new PurchaseReturnItem([
'product_id' => $itemData['product_id'],
'quantity_returned' => $itemData['quantity_returned'],
'unit_price' => $itemData['unit_price'],
'total_amount' => $amount,
'batch_number' => $itemData['batch_number'] ?? null,
]);
$purchaseReturn->items()->save($prItem);
}
$taxAmount = $purchaseReturn->tax_amount;
$purchaseReturn->update([
'total_amount' => $totalAmount,
'grand_total' => $totalAmount + $taxAmount,
]);
}
return $purchaseReturn->fresh('items');
});
}
/**
* 送出審核 / 確認退貨 (扣減倉庫庫存)
*/
public function submit(PurchaseReturn $purchaseReturn)
{
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
throw new Exception('只有草稿狀態的退回單可以提交。');
}
return DB::transaction(function () use ($purchaseReturn) {
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
$purchaseReturn->saveQuietly();
// 2. 扣減庫存
$updatedItems = [];
foreach ($purchaseReturn->items as $prItem) {
// 調用 Inventory Service 的 FIFO 扣庫存邏輯
$this->inventoryService->decreaseStock(
$prItem->product_id,
$purchaseReturn->warehouse_id,
$prItem->quantity_returned,
'採購退回 (' . $purchaseReturn->code . ')'
);
$updatedItems[] = [
'product_id' => $prItem->product_id,
'quantity_returned' => $prItem->quantity_returned,
];
}
// 3. 手動觸發合併的操作紀錄
activity()
->performedOn($purchaseReturn)
->withProperties([
'items_diff' => ['returned' => $updatedItems],
'attributes' => ['status' => PurchaseReturn::STATUS_COMPLETED],
'old' => ['status' => PurchaseReturn::STATUS_DRAFT],
'snapshot' => [
'return_no' => $purchaseReturn->code,
'vendor_id' => $purchaseReturn->vendor_id,
] // Service 層若無法拿到 warehouse 名稱,依賴 Model tapActivity 解析
])
->log('submitted');
return $purchaseReturn;
});
}
/**
* 取消退貨單
*/
public function cancel(PurchaseReturn $purchaseReturn)
{
if ($purchaseReturn->status === PurchaseReturn::STATUS_COMPLETED) {
throw new Exception('已完成扣庫的退貨單無法直接取消,需進行逆向調整。');
}
$purchaseReturn->update(['status' => PurchaseReturn::STATUS_CANCELLED]);
return $purchaseReturn;
}
/**
* 刪除退貨單
*/
public function delete(PurchaseReturn $purchaseReturn)
{
if ($purchaseReturn->status === PurchaseReturn::STATUS_COMPLETED) {
throw new Exception('已完成的退貨單無法刪除。');
}
return DB::transaction(function () use ($purchaseReturn) {
$purchaseReturn->items()->delete();
$purchaseReturn->delete();
});
}
private function generateCode(string $date)
{
// 格式: PR-YYYYMMDD-NN
$prefix = 'PR-' . date('Ymd', strtotime($date)) . '-';
$last = PurchaseReturn::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc')
->lockForUpdate()
->first();
if ($last) {
$seq = intval(substr($last->code, -2)) + 1;
} else {
$seq = 1;
}
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Modules\Production\Contracts;
interface ProductionServiceInterface
{
public function getPendingProductionCount(): int;
/**
* 尋找產出特定批號的生產工單
*
* @param string $batchNumber
* @return \Illuminate\Support\Collection
*/
public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection;
/**
* 尋找使用了特定庫存批號的生產工單項目
*
* @param int $inventoryId
* @param array $with
* @return \Illuminate\Support\Collection
*/
public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection;
}

View File

@@ -62,7 +62,11 @@ class ProductionOrderController extends Controller
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
// 分頁
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$productionOrders = $query->paginate($perPage)->withQueryString();
// --- 手動資料水和 (Manual Hydration) ---

View File

@@ -40,7 +40,13 @@ class RecipeController extends Controller
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
$recipes = $query->paginate($request->input('per_page', 10))->withQueryString();
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) $request->input('per_page', $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$recipes = $query->paginate($perPage)->withQueryString();
// Manual Hydration
$productIds = $recipes->pluck('product_id')->unique()->filter()->toArray();

View File

@@ -26,4 +26,9 @@ class ProductionOrderItem extends Model
{
return $this->belongsTo(ProductionOrder::class);
}
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class);
}
}

View File

@@ -27,13 +27,5 @@ 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);
}
// product 和 unit 關聯移至 service (跨模組)
}

View File

@@ -10,7 +10,10 @@ class ProductionServiceProvider extends ServiceProvider
{
public function register(): void
{
//
$this->app->bind(
\App\Modules\Production\Contracts\ProductionServiceInterface::class,
\App\Modules\Production\Services\ProductionService::class
);
}
public function boot(): void

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Modules\Production\Services;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
class ProductionService implements ProductionServiceInterface
{
public function getPendingProductionCount(): int
{
return ProductionOrder::where('status', 'pending')->count();
}
public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection
{
return ProductionOrder::with(['items.inventory.product', 'items.inventory'])
->where('output_batch_number', $batchNumber)
->get();
}
public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection
{
return ProductionOrderItem::with($with)
->where('inventory_id', $inventoryId)
->get();
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Modules\Sales\Contracts;
interface SalesServiceInterface
{
public function getThisMonthRevenue(): float;
public function getSalesTrend(int $days = 30): array;
public function getTopSellingProducts(int $limit = 5): \Illuminate\Support\Collection;
public function getTopSellingByQuantity(int $limit = 5): \Illuminate\Support\Collection;
}

View File

@@ -5,7 +5,7 @@ namespace App\Modules\Sales\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Sales\Models\SalesImportBatch;
use App\Modules\Sales\Imports\SalesImport;
use App\Modules\Inventory\Services\InventoryService; // Assuming this exists or we need to use ProductService
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
@@ -15,7 +15,11 @@ class SalesImportController extends Controller
{
public function index(Request $request)
{
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$search = $request->input('search');
$batches = SalesImportBatch::with('importer')
@@ -65,18 +69,37 @@ class SalesImportController extends Controller
{
$import->load(['items', 'importer']);
$perPage = $request->input('per_page', 10);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$paginatedItems = $import->items()->paginate($perPage)->withQueryString();
// Manual Hydration for Products and Warehouses
$inventoryService = app(InventoryServiceInterface::class);
$productIds = collect($paginatedItems->items())->pluck('product_id')->filter()->unique()->toArray();
$machineCodes = collect($paginatedItems->items())->pluck('machine_id')->filter()->unique()->toArray();
$products = $inventoryService->getProductsByIds($productIds)->keyBy('id');
$warehouses = $inventoryService->getWarehousesByCodes($machineCodes)->keyBy('code');
$paginatedItems->getCollection()->transform(function ($item) use ($products, $warehouses) {
$item->product = $products->get($item->product_id);
$item->warehouse = $warehouses->get($item->machine_id);
return $item;
});
return Inertia::render('Sales/Import/Show', [
'import' => $import,
'items' => $import->items()->with(['product', 'warehouse'])->paginate($perPage)->withQueryString(),
'items' => $paginatedItems,
'filters' => [
'per_page' => $perPage,
],
]);
}
public function confirm(SalesImportBatch $import, InventoryService $inventoryService)
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
{
if ($import->status !== 'pending') {
return back()->with('error', '此批次無法確認。');
@@ -87,8 +110,8 @@ class SalesImportController extends Controller
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
// Pre-load necessary warehouses for matching
$machineIds = $import->items->pluck('machine_id')->filter()->unique();
$warehouses = \App\Modules\Inventory\Models\Warehouse::whereIn('code', $machineIds)->get()->keyBy('code');
$machineIds = $import->items->pluck('machine_id')->filter()->unique()->toArray();
$warehouses = $inventoryService->getWarehousesByCodes($machineIds)->keyBy('code');
foreach ($import->items as $item) {
// Only process shipped items with a valid product

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Sales\Imports;
use App\Modules\Sales\Models\SalesImportBatch;
use App\Modules\Sales\Models\SalesImportItem;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithStartRow;
@@ -19,7 +19,8 @@ class SalesImportSheet implements ToCollection, WithStartRow
{
$this->batch = $batch;
// Pre-load all products to minimize queries (keyed by code)
$this->products = Product::pluck('id', 'code'); // assumes code is unique
$inventoryService = app(InventoryServiceInterface::class);
$this->products = $inventoryService->getAllProducts()->pluck('id', 'code')->toArray(); // assumes code is unique
}
public function startRow(): int

View File

@@ -2,8 +2,7 @@
namespace App\Modules\Sales\Models;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -38,14 +37,4 @@ class SalesImportItem extends Model
{
return $this->belongsTo(SalesImportBatch::class, 'batch_id');
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'machine_id', 'code');
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Modules\Sales;
use Illuminate\Support\ServiceProvider;
use App\Modules\Sales\Contracts\SalesServiceInterface;
use App\Modules\Sales\Services\SalesService;
class SalesServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(SalesServiceInterface::class, SalesService::class);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Modules\Sales\Services;
use App\Modules\Sales\Contracts\SalesServiceInterface;
use App\Modules\Sales\Models\SalesImportItem;
use Illuminate\Support\Facades\DB;
class SalesService implements SalesServiceInterface
{
public function getThisMonthRevenue(): float
{
return (float) SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->sum('amount');
}
public function getSalesTrend(int $days = 30): array
{
$startDate = now()->subDays($days - 1)->startOfDay();
$salesData = SalesImportItem::where('transaction_at', '>=', $startDate)
->selectRaw('DATE(transaction_at) as date, SUM(amount) as total')
->groupBy('date')
->orderBy('date')
->get()
->mapWithKeys(function ($item) {
return [$item->date => (int)$item->total];
});
$salesTrend = [];
for ($i = 0; $i < $days; $i++) {
$date = $startDate->copy()->addDays($i)->format('Y-m-d');
$salesTrend[] = [
'date' => $startDate->copy()->addDays($i)->format('m/d'),
'amount' => $salesData[$date] ?? 0,
];
}
return $salesTrend;
}
public function getTopSellingProducts(int $limit = 5): \Illuminate\Support\Collection
{
return SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', DB::raw('SUM(amount) as total_amount'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_amount')
->limit($limit)
->get();
}
public function getTopSellingByQuantity(int $limit = 5): \Illuminate\Support\Collection
{
return SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_quantity')
->limit($limit)
->get();
}
}

View File

@@ -76,6 +76,11 @@ services:
container_name: star-erp-proxy
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '8080:8080'
- '8081:8081'
volumes:
- './nginx/local-proxy.conf:/etc/nginx/conf.d/default.conf:ro'
networks:
- sail
depends_on:

View File

@@ -0,0 +1,49 @@
<?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('purchase_returns', function (Blueprint $table) {
$table->id();
$table->string('code')->unique()->comment('退回單號');
$table->unsignedBigInteger('vendor_id')->comment('廠商 ID');
$table->unsignedBigInteger('warehouse_id')->comment('退貨出庫倉庫 ID');
$table->unsignedBigInteger('user_id')->comment('建立者 ID');
$table->date('return_date')->comment('退貨日期');
$table->string('status', 20)->default('draft')->comment('狀態: draft, completed, cancelled');
$table->decimal('total_amount', 12, 2)->default(0)->comment('總金額 (不含稅)');
$table->decimal('tax_amount', 12, 2)->default(0)->comment('稅金');
$table->decimal('grand_total', 12, 2)->default(0)->comment('總計含稅金額');
$table->text('remarks')->nullable()->comment('備註');
$table->timestamps();
$table->softDeletes();
// 由於 Modular Monolith 規範,外部模組的 Foreign Key 不一定建立實體約束以避免牽一髮動全身
// $table->foreign('vendor_id')->references('id')->on('vendors')->onDelete('restrict');
$table->index('code');
$table->index('vendor_id');
$table->index('warehouse_id');
$table->index('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchase_returns');
}
};

View File

@@ -0,0 +1,38 @@
<?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('purchase_return_items', function (Blueprint $table) {
$table->id();
$table->foreignId('purchase_return_id')->constrained('purchase_returns')->onDelete('cascade');
$table->unsignedBigInteger('product_id')->comment('商品 ID');
$table->decimal('quantity_returned', 10, 2)->comment('退貨數量');
$table->decimal('unit_price', 12, 2)->comment('退貨單價');
$table->decimal('total_amount', 12, 2)->comment('小計金額');
$table->string('batch_number', 100)->nullable()->comment('指定退回之批號');
$table->timestamps();
$table->index('product_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchase_return_items');
}
};

View File

@@ -0,0 +1,29 @@
<?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('system_settings', function (Blueprint $table) {
$table->id();
$table->string('group')->index()->comment('設定分組');
$table->string('key')->unique()->comment('設定鍵名');
$table->text('value')->nullable()->comment('設定值');
$table->string('type')->default('string')->comment('資料型別');
$table->string('description')->nullable()->comment('功能說明');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('system_settings');
}
};

View File

@@ -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('inventories', function (Blueprint $table) {
$table->decimal('reserved_quantity', 12, 4)->default(0)->after('quantity')->comment('預留/鎖定庫存數量');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventories', function (Blueprint $table) {
$table->dropColumn('reserved_quantity');
});
}
};

View File

@@ -33,6 +33,14 @@ class PermissionSeeder extends Seeder
'purchase_orders.approve' => '核准',
'purchase_orders.cancel' => '作廢',
// 採購退回管理
'purchase_returns.view' => '檢視',
'purchase_returns.create' => '建立',
'purchase_returns.edit' => '編輯',
'purchase_returns.delete' => '刪除',
'purchase_returns.approve' => '核准',
'purchase_returns.cancel' => '作廢',
// 庫存管理
'inventory.view' => '檢視',
'inventory.view_cost' => '檢視成本',
@@ -62,6 +70,9 @@ class PermissionSeeder extends Seeder
'inventory_report.view' => '檢視',
'inventory_report.export' => '匯出',
// 批號溯源
'inventory_traceability.view' => '檢視',
// 進貨單管理
'goods_receipts.view' => '檢視',
'goods_receipts.create' => '建立',
@@ -116,6 +127,10 @@ class PermissionSeeder extends Seeder
// 系統日誌
'system.view_logs' => '檢視日誌',
// 系統設定
'system.settings.view' => '檢視設定',
'system.settings.edit' => '編輯設定',
// 公共事業費管理
'utility_fees.view' => '檢視',
'utility_fees.create' => '建立',
@@ -173,11 +188,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.approve', 'purchase_orders.cancel',
'purchase_returns.view', 'purchase_returns.create', 'purchase_returns.edit',
'purchase_returns.delete', 'purchase_returns.approve', 'purchase_returns.cancel',
'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', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
'inventory_report.view', 'inventory_report.export',
'inventory_report.view', 'inventory_report.export', 'inventory_traceability.view',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete',
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete',
@@ -202,7 +219,7 @@ class PermissionSeeder extends Seeder
'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', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
'inventory_report.view', 'inventory_report.export',
'inventory_report.view', 'inventory_report.export', 'inventory_traceability.view',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'production_orders.view', 'production_orders.create', 'production_orders.edit',
@@ -215,6 +232,7 @@ class PermissionSeeder extends Seeder
$purchaser->givePermissionTo([
'products.view',
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
'purchase_returns.view', 'purchase_returns.create', 'purchase_returns.edit',
'vendors.view', 'vendors.create', 'vendors.edit',
'inventory.view',
'goods_receipts.view', 'goods_receipts.create',
@@ -224,12 +242,14 @@ class PermissionSeeder extends Seeder
$viewer->givePermissionTo([
'products.view',
'purchase_orders.view',
'purchase_returns.view',
'inventory.view',
'goods_receipts.view',
'vendors.view',
'warehouses.view',
'utility_fees.view',
'inventory_report.view',
'inventory_traceability.view',
'accounting.view',
'account_payables.view',
'sales_orders.view',

View File

@@ -0,0 +1,68 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SystemSettingSeeder extends Seeder
{
public function run(): void
{
$settings = [
// 💰 財務設定
[
'group' => 'finance',
'key' => 'finance.ap_payment_days',
'value' => '30',
'type' => 'integer',
'description' => '應付帳款預設付款天數',
],
// 📦 庫存管理
[
'group' => 'inventory',
'key' => 'inventory.expiry_warning_days',
'value' => '30',
'type' => 'integer',
'description' => '商品到期預警天數',
],
// 📊 周轉率分析
[
'group' => 'turnover',
'key' => 'turnover.analysis_period_days',
'value' => '30',
'type' => 'integer',
'description' => '周轉率分析:銷售統計期間(天)',
],
[
'group' => 'turnover',
'key' => 'turnover.dead_stock_days',
'value' => '90',
'type' => 'integer',
'description' => '周轉率分析:滯銷判定天數',
],
[
'group' => 'turnover',
'key' => 'turnover.slow_moving_days',
'value' => '60',
'type' => 'integer',
'description' => '周轉率分析:週轉慢判定天數',
],
// 🖥️ 顯示設定
[
'group' => 'display',
'key' => 'display.per_page',
'value' => '10',
'type' => 'integer',
'description' => '每頁預設筆數',
],
];
foreach ($settings as $setting) {
\App\Modules\Core\Models\SystemSetting::updateOrCreate(
['key' => $setting['key']],
$setting
);
}
}
}

29
nginx/local-proxy.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 8080;
server_name localhost;
location / {
proxy_pass http://star-erp-laravel:8080;
proxy_set_header Host $http_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-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
}
}
server {
listen 8081;
server_name localhost;
location / {
proxy_pass http://star-erp-laravel:8080;
proxy_set_header Host $http_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-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -43,10 +43,15 @@ interface Props {
// 欄位翻譯對照表
const fieldLabels: Record<string, string> = {
id: 'ID',
created_at: '建立時間',
updated_at: '更新時間',
deleted_at: '刪除時間',
name: '名稱',
code: '商品代號',
username: '登入帳號',
description: '描述',
// ... (保持原有翻譯)
price: '價格',
cost: '成本',
stock: '庫存',
@@ -66,6 +71,11 @@ const fieldLabels: Record<string, string> = {
role_id: '角色',
email_verified_at: '電子郵件驗證時間',
remember_token: '登入權杖',
barcode: '條碼',
external_pos_id: '外部 POS ID',
cost_price: '成本價',
member_price: '會員價',
wholesale_price: '批發價',
// 快照欄位
category_name: '分類名稱',
base_unit_name: '基本單位名稱',
@@ -78,6 +88,9 @@ const fieldLabels: Record<string, string> = {
contact_name: '聯絡人',
tel: '電話',
remark: '備註',
license_plate: '車牌號碼',
driver_name: '司機姓名',
default_transit_warehouse_id: '預設在途倉庫',
// 倉庫與庫存欄位
warehouse_name: '倉庫名稱',
product_name: '商品名稱',
@@ -98,6 +111,12 @@ const fieldLabels: Record<string, string> = {
quality_status: '品質狀態',
quality_remark: '品質備註',
purchase_order_id: '來源採購單',
inventory_id: '庫存 ID',
balance_before: '異動前餘額',
balance_after: '異動後餘額',
reference_type: '參考單據類型',
reference_id: '參考單據 ID',
actual_time: '實際時點',
// 採購單欄位
po_number: '採購單號',
vendor_id: '廠商',
@@ -149,6 +168,28 @@ const fieldLabels: Record<string, string> = {
posted_by: '過帳者',
counted_qty: '盤點數量',
adjust_qty: '調整數量',
// 調撥單專有欄位
transit_warehouse_id: '在途倉庫',
transit_warehouse_name: '在途倉庫名稱',
dispatched_at: '出貨日期',
dispatched_by: '出貨人',
received_at: '收貨日期',
received_by: '收貨人',
reserved_quantity: '預扣數量',
snapshot_quantity: '異動前庫存 (快照)',
// 門市叫貨欄位
store_warehouse_id: '申請倉庫',
store_warehouse_name: '申請倉庫',
supply_warehouse_id: '供貨倉庫',
supply_warehouse_name: '供貨倉庫',
approved_by: '審核人',
approved_user_name: '審核人',
approved_at: '審核時間',
submitted_at: '提交時間',
reject_reason: '駁回原因',
status_label: '處理狀態',
transfer_order_id: '調撥單 ID',
transfer_order_name: '調撥單號',
};
// 狀態翻譯對照表
@@ -173,7 +214,42 @@ const statusMap: Record<string, string> = {
in_progress: '生產中',
// 調撥單狀態
voided: '已作廢',
// completed 已定義
dispatched: '已出貨',
};
// 主體類型解析 (Model 類名轉中文)
const subjectTypeMap: Record<string, string> = {
// 完整路徑映射
'App\\Modules\\Inventory\\Models\\Product': '商品資料',
'App\\Modules\\Inventory\\Models\\Warehouse': '倉庫資料',
'App\\Modules\\Inventory\\Models\\Inventory': '庫存異動',
'App\\Modules\\Inventory\\Models\\Category': '商品分類',
'App\\Modules\\Inventory\\Models\\Unit': '單位',
'App\\Modules\\Inventory\\Models\\InventoryTransaction': '庫存異動紀錄',
'App\\Modules\\Inventory\\Models\\GoodsReceipt': '進貨單',
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單',
// 簡寫映射 (應對後端回傳 class_basename 的情況)
'Product': '商品資料',
'Warehouse': '倉庫資料',
'Inventory': '庫存異動',
'InventoryTransaction': '庫存異動紀錄',
'Category': '商品分類',
'Unit': '單位',
'Vendor': '廠商資料',
'PurchaseOrder': '採購單',
'GoodsReceipt': '進貨單',
'ProductionOrder': '生產工單',
'Recipe': '生產配方',
'InventoryCountDoc': '庫存盤點單',
'InventoryAdjustDoc': '庫存盤調單',
'InventoryTransferOrder': '庫庫調撥單',
'StoreRequisition': '門市叫貨單',
'StockMovementDoc': '庫存單據',
'User': '使用者帳號',
'Role': '角色權限',
'UtilityFee': '公共事業費',
};
// 庫存品質狀態對照表
@@ -202,43 +278,50 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
// 自訂欄位排序順序
const sortOrder = [
'po_number', 'vendor_name', 'warehouse_name', 'order_date', 'expected_delivery_date', 'status', 'remark',
'doc_no', 'po_number', 'gr_number', 'production_number', 'transfer_order_name',
'vendor_name', 'warehouse_name', 'order_date', 'expected_delivery_date', 'status', 'remark',
'invoice_number', 'invoice_date', 'invoice_amount',
'total_amount', 'tax_amount', 'grand_total' // 確保金額的特定順序
'total_amount', 'tax_amount', 'grand_total'
];
// 過濾掉通常會記錄但對使用者無用的內部鍵
// 過濾掉通常會記錄但對使用者無用的內部鍵,以及已被解析為名稱的原始 ID 欄位
const filteredKeys = allKeys
.filter(key =>
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key)
)
.filter(key => {
if (['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token', 'activityProperties'].includes(key)) return false;
// 隱藏冗餘的狀態標籤 (因為後端已統一替換 status 內容)
if (key === 'status_label') return false;
// 隱藏技術用的 ID 欄位 (如果已有對應的名稱欄位)
if (key.endsWith('_id')) {
const nameKey = key.replace('_id', '_name');
const userNameKey = key.replace('_id', '_user_name');
if (allKeys.includes(nameKey) || allKeys.includes(userNameKey)) return false;
}
// 特別隱藏調撥單 ID
if (key === 'transfer_order_id' && (allKeys.includes('transfer_order_name') || allKeys.includes('transfer_order_doc_no'))) return false;
return true;
})
.sort((a, b) => {
const indexA = sortOrder.indexOf(a);
const indexB = sortOrder.indexOf(b);
// 如果兩者都在排序順序中,比較索引
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
// 如果只有 A 在排序順序中,它排在前面(或根據邏輯,通常將已知欄位排在前面)
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// 否則按字母順序或預設
return a.localeCompare(b);
});
// 檢查鍵是否為快照名稱欄位或輔助名稱欄位的輔助函式
const isSnapshotField = (key: string) => {
// 隱藏快照欄位
const snapshotFields = [
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
'warehouse_name', 'user_name', 'from_warehouse_name', 'to_warehouse_name',
'created_by_name', 'updated_by_name', 'completed_by_name', 'posted_by_name'
'created_by_name', 'updated_by_name', 'completed_by_name', 'posted_by_name',
'vendor_name', 'product_name', 'recipe_name'
];
if (snapshotFields.includes(key)) return true;
// 隱藏所有以 _name 結尾的欄位(因為它們通常是 ID 欄位的文字補充)
if (key.endsWith('_name')) return true;
return false;
};
@@ -262,38 +345,32 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
};
const formatValue = (key: string, value: any) => {
// 遮蔽密碼
if (key === 'password') return '******';
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? '是' : '否';
if (key === 'is_active') return value ? '啟用' : '停用';
// 處理採購單狀態
if (key === 'status' && typeof value === 'string' && statusMap[value]) {
return statusMap[value];
}
// 處理庫存品質狀態
if (key === 'quality_status' && typeof value === 'string' && qualityStatusMap[value]) {
return qualityStatusMap[value];
}
// 處理入庫類型
if (key === 'type' && typeof value === 'string' && typeMap[value]) {
return typeMap[value];
}
// 處理日期欄位 (YYYY-MM-DD)
if ((key === 'order_date' || key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') {
// 僅取日期部分 (YYYY-MM-DD)
// 處理日期與時間
if ((key === 'order_date' || key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date' || key === 'received_date' || key === 'production_date') && typeof value === 'string') {
return value.split('T')[0].split(' ')[0];
}
// 處理日期時間欄位 (YYYY-MM-DD HH:mm:ss)
if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at') && typeof value === 'string') {
if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at' || key === 'created_at' || key === 'updated_at' || key === 'actual_time' || key === 'submitted_at' || key === 'approved_at') && typeof value === 'string') {
try {
const date = new Date(value);
// 處理部分 ISO 字串包含 T 的情況
const normalizedValue = value.replace('T', ' ');
const date = new Date(normalizedValue);
if (isNaN(date.getTime())) return value;
return date.toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
@@ -308,44 +385,31 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return value;
}
}
return String(value);
};
const getFormattedValue = (key: string, value: any) => {
// 如果是 ID 欄位,嘗試在快照或屬性中尋找對應名稱
if (key.endsWith('_id')) {
const nameKey = key.replace('_id', '_name');
// 先檢查快照,然後檢查屬性
const nameValue = snapshot[nameKey] || attributes[nameKey];
if (nameValue) {
return `${nameValue}`;
}
if (nameValue) return `${nameValue}`;
}
return formatValue(key, value);
};
// 取得翻譯欄位標籤的輔助函式
const getFieldLabel = (key: string) => {
return fieldLabels[key] || key;
};
const getFieldLabel = (key: string) => fieldLabels[key] || key;
const getSubjectTypeLabel = (type: string) => subjectTypeMap[type] || type;
// 取得標題的主題名稱
const getSubjectName = () => {
// 庫存的特殊處理:顯示 "倉庫 - 商品"
if ((snapshot.warehouse_name || attributes.warehouse_name) && (snapshot.product_name || attributes.product_name)) {
const wName = snapshot.warehouse_name || attributes.warehouse_name;
const pName = snapshot.product_name || attributes.product_name;
return `${wName} - ${pName}`;
return `${snapshot.warehouse_name || attributes.warehouse_name} - ${snapshot.product_name || attributes.product_name}`;
}
const nameParams = ['doc_no', 'po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title'];
const nameParams = ['doc_no', 'po_number', 'gr_number', 'production_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'title'];
for (const param of nameParams) {
if (snapshot[param]) return snapshot[param];
if (attributes[param]) return attributes[param];
if (old[param]) return old[param];
}
if (attributes.id || old.id) return `#${attributes.id || old.id}`;
return '';
};
@@ -365,7 +429,6 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</Badge>
</div>
{/* 現代化元數據條 */}
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
@@ -379,19 +442,19 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
<Package className="w-4 h-4 text-gray-400" />
<span className="font-medium text-gray-700">
{subjectName ? `${subjectName} ` : ''}
{activity.properties?.sub_subject || activity.subject_type}
({getSubjectTypeLabel(activity.properties?.sub_subject || activity.subject_type)})
</span>
</div>
{/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */}
{activity.description !== getEventLabel(activity.event) &&
activity.description !== 'created' && activity.description !== 'updated' && (
<div className="flex items-center gap-2">
<ActivityIcon className="w-4 h-4 text-gray-400" />
<span>{activity.description}</span>
</div>
)}
</div>
</DialogHeader>
{/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */}
{activity.description !== getEventLabel(activity.event) &&
activity.description !== 'created' && activity.description !== 'updated' && (
<div className="flex items-center gap-2 px-6 pb-2 -mt-2">
<ActivityIcon className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">{activity.description}</span>
</div>
)}
<div className="bg-gray-50/50 p-6 min-h-[300px]">
{activity.event === 'created' ? (
@@ -506,72 +569,122 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
</TableHeader>
<TableBody>
{/* 更新項目 */}
{activity.properties.items_diff.updated?.map((item: any, idx: number) => (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"></Badge>
{/* 1. 處理物件格式的 items_diff (現有的採購、盤點、調撥初始紀錄格式) */}
{!Array.isArray(activity.properties?.items_diff) && (
<>
{/* 更新項目 */}
{activity.properties?.items_diff?.updated?.map((item: any, idx: number) => {
const getQty = (obj: any) => obj?.quantity ?? obj?.quantity_received ?? obj?.requested_qty;
const getAmt = (obj: any) => obj?.subtotal ?? obj?.total_amount;
const oldQty = getQty(item.old);
const newQty = getQty(item.new);
const oldAmt = getAmt(item.old);
const newAmt = getAmt(item.new);
return (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"></Badge>
</TableCell>
<TableCell className="text-sm">
<div className="space-y-1 text-xs">
{oldQty !== newQty && oldQty !== undefined && (
<div>: <span className="text-gray-500 line-through">{oldQty}</span> <span className="text-blue-700 font-bold">{newQty}</span></div>
)}
{oldAmt !== newAmt && oldAmt !== undefined && (
<div>: <span className="text-gray-500 line-through">{oldAmt}</span> <span className="text-blue-700 font-bold">{newAmt}</span></div>
)}
{item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.counted_qty ?? '未盤'}</span> <span className="text-blue-700 font-bold">{item.new.counted_qty ?? '未盤'}</span></div>
)}
{item.old?.remark !== item.new?.remark && item.old?.remark !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.remark || '無'}</span> <span className="text-blue-700 font-bold">{item.new.remark || '無'}</span></div>
)}
</div>
</TableCell>
</TableRow>
);
}) || null}
{/* 新增項目 */}
{activity.properties?.items_diff?.added?.map((item: any, idx: number) => {
const qty = item.new?.quantity ?? item.new?.quantity_received ?? item.new?.requested_qty ?? item.quantity;
const amt = item.new?.subtotal ?? item.new?.total_amount;
const remark = item.new?.remark;
return (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge>
</TableCell>
<TableCell className="text-sm">
<div className="space-y-0.5 text-xs">
<div>: {qty} {item.unit_name || item.new?.unit_name || ''}</div>
{amt !== undefined && <div>: {amt}</div>}
{remark && <div>: {remark}</div>}
</div>
</TableCell>
</TableRow>
);
}) || null}
{/* 移除項目 */}
{activity.properties?.items_diff?.removed?.map((item: any, idx: number) => {
const qty = item.old?.quantity ?? item.old?.quantity_received ?? item.old?.requested_qty ?? item.quantity ?? item.quantity_received;
return (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200"></Badge>
</TableCell>
<TableCell className="text-sm text-gray-400">
: {qty} {item.unit_name || item.old?.unit_name || ''}
</TableCell>
</TableRow>
);
}) || null}
</>
)}
{/* 2. 處理陣列格式的 items_diff (調撥單過帳/收貨的複合紀錄格式) */}
{Array.isArray(activity.properties?.items_diff) && activity.properties.items_diff.map((item: any, idx: number) => (
<TableRow key={`trf-${idx}`} className="hover:bg-gray-50/50">
<TableCell className="font-medium">
{item.product_name}
<div className="text-[10px] text-gray-400 font-normal">: {item.batch_number}</div>
</TableCell>
<TableCell className="text-sm">
<div className="space-y-1">
{item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.quantity}</span> <span className="text-blue-700 font-bold">{item.new.quantity}</span></div>
<TableCell className="text-center">
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200"></Badge>
</TableCell>
<TableCell className="text-xs p-2">
<div className="flex flex-col gap-2">
{item.source_warehouse && (
<div className="flex items-center gap-2">
<span className="w-16 text-gray-400 truncate"> ({item.source_warehouse}):</span>
<span className="text-gray-400 line-through">{Number(item.source_before || 0).toFixed(0)}</span>
<span className="text-rose-600 font-bold"> {Number(item.source_after || 0).toFixed(0)}</span>
<span className="text-[10px] bg-rose-50 text-rose-600 px-1 rounded">-{item.quantity}</span>
</div>
)}
{item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.counted_qty ?? '未盤'}</span> <span className="text-blue-700 font-bold">{item.new.counted_qty ?? '未盤'}</span></div>
)}
{item.old?.adjust_qty !== item.new?.adjust_qty && (
<div>調: <span className="text-gray-500 line-through">{item.old?.adjust_qty ?? '0'}</span> <span className="text-blue-700 font-bold">{item.new?.adjust_qty ?? '0'}</span></div>
)}
{item.old?.unit_name !== item.new?.unit_name && item.old?.unit_name !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.unit_name || '-'}</span> <span className="text-blue-700 font-bold">{item.new.unit_name || '-'}</span></div>
)}
{item.old?.subtotal !== item.new?.subtotal && item.old?.subtotal !== undefined && (
<div>: <span className="text-gray-500 line-through">${item.old.subtotal}</span> <span className="text-blue-700 font-bold">${item.new.subtotal}</span></div>
)}
{item.old?.notes !== item.new?.notes && (
<div>: <span className="text-gray-500 line-through">{item.old?.notes || '-'}</span> <span className="text-blue-700 font-bold">{item.new?.notes || '-'}</span></div>
{item.target_warehouse && (
<div className="flex items-center gap-2">
<span className="w-16 text-gray-400 truncate"> ({item.target_warehouse}):</span>
<span className="text-gray-400 line-through">{Number(item.target_before || 0).toFixed(0)}</span>
<span className="text-emerald-600 font-bold"> {Number(item.target_after || 0).toFixed(0)}</span>
<span className="text-[10px] bg-emerald-50 text-emerald-600 px-1 rounded">+{item.quantity}</span>
</div>
)}
</div>
</TableCell>
</TableRow>
)) || null}
{/* 新增項目 */}
{activity.properties.items_diff.added?.map((item: any, idx: number) => (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge>
</TableCell>
<TableCell className="text-sm">
{item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''}
{item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''}
{item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''}
</TableCell>
</TableRow>
)) || null}
{/* 移除項目 */}
{activity.properties.items_diff.removed?.map((item: any, idx: number) => (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200"></Badge>
</TableCell>
<TableCell className="text-sm text-gray-400">
: {item.quantity} {item.unit_name}
</TableCell>
</TableRow>
)) || null}
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</DialogContent >
</Dialog >
);
}

View File

@@ -29,6 +29,52 @@ interface LogTableProps {
from?: number; // 起始索引編號 (paginator.from)
}
// 主體類型解析 (Model 類名轉中文)
const subjectTypeMap: Record<string, string> = {
// 完整路徑映射
'App\\Modules\\Inventory\\Models\\Product': '商品資料',
'App\\Modules\\Inventory\\Models\\Warehouse': '倉庫資料',
'App\\Modules\\Inventory\\Models\\Inventory': '庫存異動',
'App\\Modules\\Inventory\\Models\\Category': '商品分類',
'App\\Modules\\Inventory\\Models\\Unit': '單位',
'App\\Modules\\Inventory\\Models\\InventoryTransaction': '庫存異動紀錄',
'App\\Modules\\Inventory\\Models\\GoodsReceipt': '進貨單',
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單',
'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單',
'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據',
'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料',
'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單',
'App\\Modules\\Production\\Models\\ProductionOrder': '生產工單',
'App\\Modules\\Production\\Models\\Recipe': '生產配方',
'App\\Modules\\Production\\Models\\RecipeItem': '配方品項',
'App\\Modules\\Production\\Models\\ProductionOrderItem': '工單品項',
'App\\Modules\\Finance\\Models\\UtilityFee': '公共事業費',
'App\\Modules\\Core\\Models\\User': '使用者帳號',
'App\\Modules\\Core\\Models\\Role': '角色權限',
// 簡寫映射
'Product': '商品資料',
'Warehouse': '倉庫資料',
'Inventory': '庫存異動',
'InventoryTransaction': '庫存異動紀錄',
'Category': '商品分類',
'Unit': '單位',
'Vendor': '廠商資料',
'PurchaseOrder': '採購單',
'GoodsReceipt': '進貨單',
'ProductionOrder': '生產工單',
'Recipe': '生產配方',
'InventoryCountDoc': '庫存盤點單',
'InventoryAdjustDoc': '庫存盤調單',
'InventoryTransferOrder': '庫存調撥單',
'StoreRequisition': '門市叫貨單',
'StockMovementDoc': '庫存單據',
'User': '使用者帳號',
'Role': '角色權限',
'UtilityFee': '公共事業費',
};
export default function LogTable({
activities,
sortField,
@@ -37,6 +83,8 @@ export default function LogTable({
onViewDetail,
from = 1
}: LogTableProps) {
const getSubjectTypeLabel = (type: string) => subjectTypeMap[type] || type;
const getEventBadgeClass = (event: string) => {
switch (event) {
case 'created': return 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100';
@@ -111,7 +159,7 @@ export default function LogTable({
{props.sub_subject ? (
<span className="text-gray-700">{props.sub_subject}</span>
) : (
<span className="text-gray-700">{activity.subject_type}</span>
<span className="text-gray-700">{getSubjectTypeLabel(activity.subject_type)}</span>
)}
{/* 如果有原因/來源則顯示(例如:來自補貨) */}
@@ -185,7 +233,7 @@ export default function LogTable({
</TableCell>
<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}
{getSubjectTypeLabel(activity.subject_type)}
</Badge>
</TableCell>
<TableCell className="text-center">

View File

@@ -0,0 +1,100 @@
import { useState } from "react";
import { Pencil, Eye, Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Link, useForm } from "@inertiajs/react";
import type { PurchaseReturn } from "@/types/purchase-return";
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 function PurchaseReturnActions({
purchaseReturn,
}: { purchaseReturn: PurchaseReturn }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { delete: destroy, processing } = useForm({});
const handleConfirmDelete = () => {
// @ts-ignore
destroy(route('purchase-returns.destroy', purchaseReturn.id), {
onSuccess: () => {
toast.success("採購退回單已成功刪除");
setShowDeleteDialog(false);
},
onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"),
});
};
return (
<div className="flex justify-center gap-2">
<Link href={`/purchase-returns/${purchaseReturn.id}`}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查看詳情"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
{purchaseReturn.status === 'draft' && (
<Can permission="purchase_returns.edit">
<Link href={`/purchase-returns/${purchaseReturn.id}/edit`}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
)}
{purchaseReturn.status === 'draft' && (
<Can permission="purchase_returns.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>
退 {purchaseReturn.code}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="button-filled-error"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
)}
</div>
);
}

View File

@@ -0,0 +1,214 @@
import { Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import type { PurchaseReturnItem } from "@/types/purchase-return";
import type { Supplier } from "@/types/purchase-order";
import { formatCurrency } from "@/utils/format";
interface PurchaseReturnItemsTableProps {
items: PurchaseReturnItem[];
vendor?: Supplier;
isReadOnly?: boolean;
isDisabled?: boolean;
onRemoveItem?: (index: number) => void;
onItemChange?: (index: number, field: keyof PurchaseReturnItem, value: string | number) => void;
}
export function PurchaseReturnItemsTable({
items,
vendor,
isReadOnly = false,
isDisabled = false,
onRemoveItem,
onItemChange,
}: PurchaseReturnItemsTableProps) {
return (
<div className={`border rounded-lg overflow-hidden ${isDisabled ? "opacity-50 pointer-events-none grayscale" : ""}`}>
<Table>
<TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[30%] text-left">退</TableHead>
<TableHead className="w-[15%] text-left">退</TableHead>
<TableHead className="w-[15%] text-left">退</TableHead>
<TableHead className="w-[15%] text-left">退</TableHead>
<TableHead className="w-[20%] text-left"> / </TableHead>
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell
colSpan={isReadOnly ? 5 : 6}
className="text-center text-gray-400 py-12 italic"
>
{isDisabled ? "請先選擇供應商後才能加入退回商品" : "尚未新增任何退回品項"}
</TableCell>
</TableRow>
) : (
items.map((item, index) => {
return (
<TableRow key={index}>
{/* 商品選擇 */}
<TableCell>
{isReadOnly ? (
<span className="font-medium">{item.product?.name || item.product_name}</span>
) : (
<SearchableSelect
value={String(item.product_id)}
onValueChange={(value) =>
onItemChange?.(index, "product_id", value)
}
disabled={isDisabled}
options={vendor?.commonProducts?.map((p) => ({ label: p.productName, value: String(p.productId) })) || []}
placeholder="選擇退回商品"
searchPlaceholder="搜尋商品..."
emptyText="此供應商無可用商品"
className="w-full"
/>
)}
</TableCell>
{/* 數量 */}
<TableCell className="text-left">
{isReadOnly ? (
<span>{item.quantity_returned}</span>
) : (
<Input
type="number"
min="0.01"
step="any"
value={item.quantity_returned || ""}
onChange={(e) =>
onItemChange?.(index, "quantity_returned", Number(e.target.value))
}
disabled={isDisabled}
className="text-right w-full"
/>
)}
</TableCell>
{/* 單價 */}
<TableCell className="text-left">
{isReadOnly ? (
<span>{formatCurrency(item.unit_price)}</span>
) : (
<Input
type="number"
min="0"
step="any"
value={item.unit_price === 0 ? "" : item.unit_price}
onChange={(e) =>
onItemChange?.(index, "unit_price", Number(e.target.value))
}
disabled={isDisabled}
className="text-right w-full"
/>
)}
</TableCell>
{/* 總退款金額 */}
<TableCell className="text-left">
{isReadOnly ? (
<span className="font-bold text-primary">{formatCurrency(item.total_amount)}</span>
) : (
<div className="space-y-1">
<Input
type="number"
min="0"
step="any"
value={item.total_amount || ""}
onChange={(e) =>
onItemChange?.(index, "total_amount", Number(e.target.value))
}
disabled={isDisabled}
className={`text-right w-full ${item.quantity_returned > 0 && (!item.total_amount || item.total_amount <= 0)
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
: ""
}`}
/>
</div>
)}
</TableCell>
{/* 批號 */}
<TableCell className="text-left">
{isReadOnly ? (
<span className="text-gray-500">{item.batch_number}</span>
) : (
<Input
type="text"
value={item.batch_number || ""}
onChange={(e) =>
onItemChange?.(index, "batch_number", e.target.value)
}
disabled={isDisabled}
className="w-full"
placeholder="輸入批號或備註..."
/>
)}
</TableCell>
{/* 刪除按鈕 */}
{!isReadOnly && onRemoveItem && (
<TableCell className="text-center">
<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>
);
})
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Badge } from "@/Components/ui/badge";
import { PurchaseReturnStatus, PURCHASE_RETURN_STATUS_CONFIG } from "@/types/purchase-return";
interface PurchaseReturnStatusBadgeProps {
status: PurchaseReturnStatus | string;
className?: string;
}
export default function PurchaseReturnStatusBadge({
status,
className = "",
}: PurchaseReturnStatusBadgeProps) {
const config = PURCHASE_RETURN_STATUS_CONFIG[status as PurchaseReturnStatus] || {
label: status,
color: "bg-gray-100 text-gray-800",
};
return (
<Badge
variant="secondary"
className={`${config.color} whitespace-nowrap min-w-[72px] justify-center ${className}`}
>
{config.label}
</Badge>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useMemo } from "react";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { PurchaseReturnActions } from "./PurchaseReturnActions";
import PurchaseReturnStatusBadge from "./PurchaseReturnStatusBadge";
import CopyButton from "@/Components/shared/CopyButton";
import type { PurchaseReturn } from "@/types/purchase-return";
import { formatCurrency, formatDateTime } from "@/utils/format";
interface PurchaseReturnTableProps {
purchaseReturns: PurchaseReturn[];
}
type SortField = "code" | "warehouse_name" | "vendor_name" | "return_date" | "total_amount" | "status";
type SortDirection = "asc" | "desc" | null;
export default function PurchaseReturnTable({
purchaseReturns,
}: PurchaseReturnTableProps) {
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 sortedReturns = useMemo(() => {
if (!sortField || !sortDirection || !purchaseReturns) {
return purchaseReturns || [];
}
return [...purchaseReturns].sort((a, b) => {
let aValue: string | number;
let bValue: string | number;
switch (sortField) {
case "code":
aValue = a.code;
bValue = b.code;
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 "return_date":
aValue = a.return_date;
bValue = b.return_date;
break;
case "total_amount":
aValue = a.total_amount;
bValue = b.total_amount;
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);
}
});
}, [purchaseReturns, sortField, sortDirection]);
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
};
return (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-gray-50/50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("code")}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
退
<SortIcon field="code" />
</button>
</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("vendor_name")}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
<SortIcon field="vendor_name" />
</button>
</TableHead>
<TableHead className="w-[150px]">
<button
onClick={() => handleSort("return_date")}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
退
<SortIcon field="return_date" />
</button>
</TableHead>
<TableHead className="w-[140px] text-right">
<button
onClick={() => handleSort("total_amount")}
className="flex items-center justify-end gap-1 w-full 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 justify-center gap-1 w-full hover:text-foreground transition-colors"
>
<SortIcon field="status" />
</button>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!sortedReturns || sortedReturns.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-12">
退
</TableCell>
</TableRow>
) : (
sortedReturns.map((item, index) => (
<TableRow key={item.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">{item.code}</span>
<CopyButton text={item.code} label="複製單號" />
</div>
</TableCell>
<TableCell>
<div className="space-y-0.5">
<span className="text-sm text-gray-700">{item.vendor?.name}</span>
<div className="text-xs text-gray-500">{item.user?.name}</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-gray-500">{item.return_date}</span>
</TableCell>
<TableCell className="text-right">
<span className="font-semibold text-gray-900">{formatCurrency(item.total_amount)}</span>
</TableCell>
<TableCell className="text-center">
<PurchaseReturnStatusBadge status={item.status} />
</TableCell>
<TableCell className="text-center">
<PurchaseReturnActions
purchaseReturn={item}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -1,193 +0,0 @@
/**
* 新增供貨商品對話框
*/
import { useState, useMemo } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/Components/ui/dialog";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/Components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/Components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Product } from "@/types/product";
import type { SupplyProduct } from "@/types/vendor";
interface AddSupplyProductDialogProps {
open: boolean;
products: Product[];
existingSupplyProducts: SupplyProduct[];
onClose: () => void;
onAdd: (productId: string, lastPrice?: number) => void;
}
export default function AddSupplyProductDialog({
open,
products,
existingSupplyProducts,
onClose,
onAdd,
}: AddSupplyProductDialogProps) {
const [selectedProductId, setSelectedProductId] = useState<string>("");
const [lastPrice, setLastPrice] = useState<string>("");
const [openCombobox, setOpenCombobox] = useState(false);
// 過濾掉已經在供貨列表中的商品
const availableProducts = useMemo(() => {
const existingIds = new Set(existingSupplyProducts.map(sp => String(sp.productId)));
return products.filter(p => !existingIds.has(String(p.id)));
}, [products, existingSupplyProducts]);
const selectedProduct = availableProducts.find(p => p.id === selectedProductId);
const handleAdd = () => {
if (!selectedProductId) return;
const price = lastPrice ? parseFloat(lastPrice) : undefined;
onAdd(selectedProductId, price);
// 重置表單
setSelectedProductId("");
setLastPrice("");
};
const handleCancel = () => {
setSelectedProductId("");
setLastPrice("");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 商品選擇 */}
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium"></Label>
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={openCombobox}
className="flex h-9 w-full items-center justify-between rounded-md border-2 border-grey-3 !bg-grey-5 px-3 py-1 text-sm font-normal text-grey-0 text-left outline-none transition-colors hover:!bg-grey-5 hover:border-primary/50 focus-visible:border-[var(--primary-main)] focus-visible:ring-[3px] focus-visible:ring-[var(--primary-main)]/20"
onClick={() => setOpenCombobox(!openCombobox)}
>
{selectedProduct ? (
<span className="font-medium text-gray-900">{selectedProduct.name}</span>
) : (
<span className="text-gray-400">...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[450px] p-0 shadow-lg border-2 z-[9999]" align="start">
<Command>
<CommandInput placeholder="搜尋商品名稱..." />
<CommandList className="max-h-[300px]">
<CommandEmpty className="py-6 text-center text-sm text-gray-500">
</CommandEmpty>
<CommandGroup>
{availableProducts.map((product) => (
<CommandItem
key={product.id}
value={product.name}
onSelect={() => {
setSelectedProductId(product.id);
setOpenCombobox(false);
}}
className="cursor-pointer aria-selected:bg-primary/5 aria-selected:text-primary py-3"
>
<Check
className={cn(
"mr-2 h-4 w-4 text-primary",
selectedProductId === product.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center justify-between flex-1">
<span className="font-medium">{product.name}</span>
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded">
{product.baseUnit?.name || (product.base_unit as any)?.name || product.base_unit || "個"}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 單位(自動帶入) */}
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium text-gray-500"></Label>
<div className="h-10 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-gray-600 font-medium text-sm flex items-center">
{selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "-"}
</div>
</div>
{/* 上次採購價格 */}
<div>
<Label className="text-muted-foreground text-xs">
/ {selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "單位"}
</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="gap-2 button-outlined-primary"
>
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!selectedProductId}
className="gap-2 button-filled-primary"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,119 +0,0 @@
/**
* 編輯供貨商品對話框
*/
import { useEffect, useState } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/Components/ui/dialog";
import type { SupplyProduct } from "@/types/vendor";
interface EditSupplyProductDialogProps {
open: boolean;
product: SupplyProduct | null;
onClose: () => void;
onSave: (productId: string, lastPrice?: number) => void;
}
export default function EditSupplyProductDialog({
open,
product,
onClose,
onSave,
}: EditSupplyProductDialogProps) {
const [lastPrice, setLastPrice] = useState<string>("");
useEffect(() => {
if (product) {
setLastPrice(product.lastPrice?.toString() || "");
}
}, [product, open]);
const handleSave = () => {
if (!product) return;
const price = lastPrice ? parseFloat(lastPrice) : undefined;
onSave(product.productId, price);
setLastPrice("");
};
const handleCancel = () => {
setLastPrice("");
onClose();
};
if (!product) return null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 商品名稱(不可編輯) */}
<div>
<Label className="text-muted-foreground text-xs"></Label>
<Input
value={product.productName}
disabled
className="mt-1 bg-muted"
/>
</div>
{/* 單位(不可編輯) */}
<div>
<Label className="text-muted-foreground text-xs"></Label>
<Input
value={product.unit}
disabled
className="mt-1 bg-muted"
/>
</div>
{/* 上次採購價格 */}
<div>
<Label className="text-muted-foreground text-xs"> / {product.baseUnit || "單位"}</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="gap-2 button-outlined-primary"
>
</Button>
<Button
size="sm"
onClick={handleSave}
className="gap-2 button-filled-primary"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,7 @@
import { Pencil, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
@@ -8,90 +10,129 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import type { SupplyProduct } from "@/types/vendor";
interface SupplyProductListProps {
products: SupplyProduct[];
onEdit: (product: SupplyProduct) => void;
onRemove: (product: SupplyProduct) => void;
items: SupplyProduct[];
allProducts: any[];
onRemoveItem: (index: number) => void;
onItemChange: (index: number, field: keyof SupplyProduct, value: string | number) => void;
}
export default function SupplyProductList({
products,
onEdit,
onRemove,
items,
allProducts,
onRemoveItem,
onItemChange,
}: SupplyProductListProps) {
return (
<div className="bg-white rounded-lg border shadow-sm">
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableRow className="bg-gray-50 hover:bg-gray-50 text-grey-0">
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">
<TableHead className="w-[40%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
<TableHead className="w-[20%] text-left">
<div className="text-xs font-normal text-muted-foreground">()</div>
<span className="text-[10px] font-normal text-muted-foreground block">()</span>
</TableHead>
<TableHead className="text-center w-[150px]"></TableHead>
<TableHead className="text-center w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
<TableCell colSpan={5} className="text-center text-muted-foreground py-12 italic text-sm">
</TableCell>
</TableRow>
) : (
products.map((product, index) => (
<TableRow key={product.id}>
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell>{product.productName}</TableCell>
<TableCell>
{product.baseUnit || product.unit || "-"}
<SearchableSelect
value={item.productId}
onValueChange={(value) => onItemChange(index, "productId", value)}
options={allProducts.map(p => ({
label: p.name,
value: String(p.id)
}))}
placeholder="選擇商品"
searchPlaceholder="搜尋商品..."
className="w-full h-10"
/>
</TableCell>
<TableCell>
{product.largeUnit && product.conversionRate ? (
<span className="text-sm text-gray-500">
1 {product.largeUnit} = {Number(product.conversionRate)} {product.baseUnit || product.unit}
</span>
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
{product.lastPrice ? (
<span>
${product.lastPrice.toLocaleString()} / {product.baseUnit || product.unit || "單位"}
</span>
) : (
"-"
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(product)}
className="button-outlined-primary"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onRemove(product)}
className="button-outlined-error"
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="h-10 px-3 py-2 bg-gray-50/50 border border-border rounded-md text-gray-600 font-medium text-sm flex items-center">
{item.baseUnit || "-"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs">$</span>
<Input
type="number"
min="0"
step="any"
value={item.lastPrice === undefined ? "" : item.lastPrice}
onChange={(e) => onItemChange(index, "lastPrice", e.target.value === "" ? 0 : Number(e.target.value))}
placeholder="0.00"
className="pl-6 h-10 w-full text-right"
/>
</div>
</div>
</TableCell>
<TableCell className="text-center">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error h-10 w-10 p-0"
title="移除項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{item.productName || "此商品"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveItem(index)}
className="bg-red-600 hover:bg-red-700 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}

View File

@@ -26,7 +26,8 @@ import {
ArrowLeftRight,
TrendingUp,
FileUp,
Store
Store,
RotateCcw
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo, useRef } from "react";
@@ -168,13 +169,20 @@ export default function AuthenticatedLayout({
route: "/goods-receipts",
permission: "goods_receipts.view",
},
/* {
id: "purchase-return-list",
label: "採購退回單",
icon: <RotateCcw className="h-4 w-4" />,
route: "/purchase-returns",
permission: "purchase_returns.view",
},
{
id: "delivery-note-list",
label: "出貨單管理 (功能製作中)",
icon: <Package className="h-4 w-4" />,
route: "/delivery-notes",
permission: "delivery_notes.view",
},
}, */
],
},
{
@@ -245,7 +253,7 @@ export default function AuthenticatedLayout({
},
{
id: "report-management",
label: "報表管理",
label: "報表與分析",
icon: <BarChart3 className="h-5 w-5" />,
permission: ["accounting.view", "inventory_report.view"],
children: [
@@ -270,6 +278,13 @@ export default function AuthenticatedLayout({
route: "/inventory/analysis",
permission: "inventory_report.view",
},
{
id: "inventory-traceability",
label: "批號溯源",
icon: <TrendingUp className="h-4 w-4" />,
route: "/inventory/traceability",
permission: "inventory_traceability.view",
},
],
},
{
@@ -299,6 +314,13 @@ export default function AuthenticatedLayout({
route: "/admin/activity-logs",
permission: "system.view_logs",
},
{
id: "system-settings",
label: "系統設定",
icon: <Settings className="h-4 w-4" />,
route: "/admin/settings",
permission: "system.settings.view",
},
{
id: "manual",
label: "操作手冊",

Some files were not shown because too many files have changed in this diff Show More