Compare commits
15 Commits
ad91b08dbc
...
036f4a4fb6
| Author | SHA1 | Date | |
|---|---|---|---|
| 036f4a4fb6 | |||
| 0a955fb993 | |||
| 7dac2d1f77 | |||
| 649af40919 | |||
| 5f8b2a1c2d | |||
| 4bbbde685d | |||
| 5e32526471 | |||
| f960aaaeb2 | |||
| 63e4f88a14 | |||
| e3df090afd | |||
| 878b90e2ad | |||
| 299cf37054 | |||
| 5668e17e61 | |||
| c4908533a8 | |||
| deef3baacc |
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||
@@ -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)** 進行。
|
||||
@@ -2,10 +2,6 @@
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 開發框架規範說明書:ERP 系統 (star-erp)
|
||||
|
||||
## 1. 專案概述
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
name: 權限管理與實作規範
|
||||
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||
99
.agents/rules/ui-consistency.md
Normal file
99
.agents/rules/ui-consistency.md
Normal 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`
|
||||
@@ -22,6 +22,7 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Procurement\Models\PurchaseOrder' => '採購單',
|
||||
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
|
||||
'App\Modules\Inventory\Models\Inventory' => '庫存',
|
||||
'App\Modules\Inventory\Models\InventoryTransaction' => '庫存異動紀錄',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
|
||||
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
|
||||
@@ -31,12 +32,17 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
|
||||
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
|
||||
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
|
||||
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
|
||||
];
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$sortBy = $request->input('sort_by', 'created_at');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Sales\Contracts\SalesServiceInterface;
|
||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -13,13 +15,19 @@ class DashboardController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
protected $salesService;
|
||||
protected $productionService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
ProcurementServiceInterface $procurementService,
|
||||
SalesServiceInterface $salesService,
|
||||
ProductionServiceInterface $productionService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
$this->salesService = $salesService;
|
||||
$this->productionService = $productionService;
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -35,94 +43,65 @@ 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) {
|
||||
$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' => $item->product ? $item->product->name : $item->product_code,
|
||||
'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) {
|
||||
$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' => $item->product ? $item->product->name : 'Unknown Product',
|
||||
'code' => $item->product ? $item->product->code : '',
|
||||
'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) {
|
||||
$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' => $item->product ? $item->product->name : $item->product_code,
|
||||
'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) {
|
||||
$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' => $item->product ? $item->product->name : 'Unknown Product',
|
||||
'name' => $product ? $product->name : 'Unknown Product',
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $item->expiry_date->format('Y-m-d'),
|
||||
'quantity' => (int)$item->quantity,
|
||||
|
||||
@@ -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' => '配方管理',
|
||||
|
||||
46
app/Modules/Core/Controllers/SystemSettingController.php
Normal file
46
app/Modules/Core/Controllers/SystemSettingController.php
Normal 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', '系統設定已更新');
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,12 @@ class UserController extends Controller
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
$search = $request->input('search');
|
||||
|
||||
61
app/Modules/Core/Models/SystemSetting.php
Normal file
61
app/Modules/Core/Models/SystemSetting.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SystemSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'value',
|
||||
'type',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* 同請求內的記憶體快取,避免重複查詢 DB
|
||||
* PHP 請求結束後自動釋放,無需額外處理失效
|
||||
*/
|
||||
protected static array $cache = [];
|
||||
|
||||
/**
|
||||
* 取得特定設定值(含記憶體快取)
|
||||
*/
|
||||
public static function getVal(string $key, $default = null)
|
||||
{
|
||||
if (array_key_exists($key, static::$cache)) {
|
||||
return static::$cache[$key];
|
||||
}
|
||||
|
||||
$setting = self::where('key', $key)->first();
|
||||
|
||||
if (!$setting) {
|
||||
static::$cache[$key] = $default;
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = $setting->value;
|
||||
|
||||
// 根據 type 進行類別轉換
|
||||
$resolved = match ($setting->type) {
|
||||
'integer', 'number' => (int) $value,
|
||||
'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
||||
'json', 'array' => json_decode($value, true),
|
||||
default => $value,
|
||||
};
|
||||
|
||||
static::$cache[$key] = $resolved;
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除記憶體快取(儲存設定後應呼叫)
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
static::$cache = [];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Modules\Core\Controllers\ProfileController;
|
||||
use App\Modules\Core\Controllers\RoleController;
|
||||
use App\Modules\Core\Controllers\UserController;
|
||||
use App\Modules\Core\Controllers\ActivityLogController;
|
||||
use App\Modules\Core\Controllers\SystemSettingController;
|
||||
|
||||
// 登入/登出路由
|
||||
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (跨模組)
|
||||
|
||||
/**
|
||||
* 關聯:建立者
|
||||
|
||||
@@ -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']} 自動生成",
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', [
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
|
||||
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
|
||||
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
|
||||
$dt->setTimeFrom(now());
|
||||
} 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();
|
||||
}
|
||||
$dt->setSecond(now()->second);
|
||||
}
|
||||
$inboundDateTime = $dt->toDateTimeString();
|
||||
|
||||
$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(),
|
||||
$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', '庫存資料已更新');
|
||||
|
||||
@@ -35,7 +35,12 @@ class InventoryReportController extends Controller
|
||||
$filters['date_to'] = date('Y-m-d');
|
||||
}
|
||||
|
||||
$reportData = $this->reportService->getReportData($filters, $request->input('per_page', 10));
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$reportData = $this->reportService->getReportData($filters, $perPage);
|
||||
$summary = $this->reportService->getSummary($filters);
|
||||
|
||||
return Inertia::render('Inventory/Report/Index', [
|
||||
|
||||
@@ -16,6 +16,12 @@ use App\Modules\Inventory\Imports\ProductImport;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
protected $productService;
|
||||
|
||||
public function __construct(\App\Modules\Inventory\Contracts\ProductServiceInterface $productService)
|
||||
{
|
||||
$this->productService = $productService;
|
||||
}
|
||||
/**
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
@@ -37,9 +43,11 @@ class ProductController extends Controller
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Services\TraceabilityService;
|
||||
|
||||
class TraceabilityController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TraceabilityService $traceabilityService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 顯示批號溯源查詢的主頁面
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$batchNumber = $request->input('batch_number');
|
||||
$direction = $request->input('direction', 'backward'); // backward 或 forward
|
||||
|
||||
$result = null;
|
||||
|
||||
if ($batchNumber) {
|
||||
if ($direction === 'backward') {
|
||||
$result = $this->traceabilityService->traceBackward($batchNumber);
|
||||
} else {
|
||||
$result = $this->traceabilityService->traceForward($batchNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Inventory/Traceability/Index', [
|
||||
'search' => [
|
||||
'batch_number' => $batchNumber,
|
||||
'direction' => $direction,
|
||||
],
|
||||
'result' => $result
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -96,6 +100,24 @@ class TransferOrderController extends Controller
|
||||
$transitWarehouseId
|
||||
);
|
||||
|
||||
// 手動發送「已建立」日誌,因為服務層使用了 saveQuietly 抑制自動日誌
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->id())
|
||||
->event('created')
|
||||
->withProperties([
|
||||
'attributes' => [
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_id' => $order->from_warehouse_id,
|
||||
'to_warehouse_id' => $order->to_warehouse_id,
|
||||
'transit_warehouse_id' => $order->transit_warehouse_id,
|
||||
'remarks' => $order->remarks,
|
||||
'status' => $order->status,
|
||||
'created_by' => $order->created_by,
|
||||
]
|
||||
])
|
||||
->log('created');
|
||||
|
||||
if ($request->input('instant_post') === true) {
|
||||
try {
|
||||
$this->transferService->dispatch($order, auth()->id());
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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') // 帳面庫存 = 所有庫存總和
|
||||
|
||||
@@ -23,7 +23,11 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
|
||||
{
|
||||
HeadingRowFormatter::default('none');
|
||||
$this->warehouse = $warehouse;
|
||||
$this->inboundDate = $inboundDate;
|
||||
|
||||
// 修正時間精度:將選定的日期與「現在的時分秒」結合
|
||||
// 這樣既能保留使用者選的日期,又能提供精確的紀錄時點排順序
|
||||
$this->inboundDate = \Illuminate\Support\Carbon::parse($inboundDate)->setTimeFrom(now())->toDateTimeString();
|
||||
|
||||
$this->notes = $notes;
|
||||
}
|
||||
|
||||
@@ -95,7 +99,7 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
|
||||
// 更新單價與總價值
|
||||
$inventory->unit_cost = $unitCost;
|
||||
$inventory->total_value = $inventory->quantity * $unitCost;
|
||||
$inventory->save();
|
||||
$inventory->saveQuietly();
|
||||
|
||||
// 記錄交易歷史
|
||||
$inventory->transactions()->create([
|
||||
|
||||
@@ -9,46 +9,87 @@ 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
|
||||
* 資料映射:將 Excel 原始標題(含「(選填)」)對應到乾淨的鍵名
|
||||
*
|
||||
* @return array
|
||||
* 注意: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['條碼'];
|
||||
}
|
||||
$code = $row['商品代號(選填)'] ?? $row['商品代號'] ?? null;
|
||||
$barcode = $row['條碼(選填)'] ?? $row['條碼'] ?? null;
|
||||
|
||||
return $row;
|
||||
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)
|
||||
{
|
||||
@@ -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 [
|
||||
'商品代號(選填)' => '商品代號',
|
||||
'條碼(選填)' => '條碼',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,6 +17,7 @@ class Inventory extends Model
|
||||
'warehouse_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'reserved_quantity',
|
||||
'location',
|
||||
'unit_cost',
|
||||
'total_value',
|
||||
@@ -34,6 +35,8 @@ class Inventory extends Model
|
||||
protected $casts = [
|
||||
'arrival_date' => 'date:Y-m-d',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
'quantity' => 'decimal:4',
|
||||
'reserved_quantity' => 'decimal:4',
|
||||
'unit_cost' => 'decimal:4',
|
||||
'total_value' => 'decimal:4',
|
||||
];
|
||||
@@ -55,8 +58,11 @@ class Inventory extends Model
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$attributes = $properties['attributes'] ?? [];
|
||||
// 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改
|
||||
@@ -66,11 +72,28 @@ class Inventory extends Model
|
||||
|
||||
// 如果已設定原因,則進行捕捉
|
||||
if ($this->activityLogReason) {
|
||||
$attributes['_reason'] = $this->activityLogReason;
|
||||
$properties['attributes']['_reason'] = $this->activityLogReason;
|
||||
}
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 全域 ID 轉名稱邏輯
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 倉庫 ID 轉換
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
|
||||
}
|
||||
// 商品 ID 轉換
|
||||
if (isset($data['product_id']) && is_numeric($data['product_id'])) {
|
||||
$data['product_id'] = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
@@ -109,7 +132,33 @@ class Inventory extends Model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 可用庫存(實體庫存 - 預留庫存)
|
||||
*/
|
||||
public function getAvailableQuantityAttribute()
|
||||
{
|
||||
return max(0, $this->quantity - $this->reserved_quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加預留庫存(鎖定)
|
||||
*/
|
||||
public function reserveQuantity(float|int $amount)
|
||||
{
|
||||
if ($amount <= 0) return;
|
||||
$this->reserved_quantity += $amount;
|
||||
$this->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* 釋放預留庫存(解鎖)
|
||||
*/
|
||||
public function releaseReservedQuantity(float|int $amount)
|
||||
{
|
||||
if ($amount <= 0) return;
|
||||
$this->reserved_quantity = max(0, $this->reserved_quantity - $amount);
|
||||
$this->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生批號
|
||||
|
||||
@@ -10,6 +10,7 @@ class InventoryTransaction extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
|
||||
use HasFactory;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
@@ -41,4 +42,49 @@ class InventoryTransaction extends Model
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->dontLogIfAttributesChangedOnly(['updated_at'])
|
||||
// 取消 logOnlyDirty,代表新增時(created)也要留紀錄
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// 試著取得商品與倉庫名稱來作為主要顯示依據
|
||||
$inventory = $this->inventory;
|
||||
if ($inventory) {
|
||||
$snapshot['warehouse_name'] = $inventory->warehouse ? $inventory->warehouse->name : null;
|
||||
$snapshot['product_name'] = $inventory->product ? $inventory->product->name : null;
|
||||
$snapshot['batch_number'] = $inventory->batch_number;
|
||||
}
|
||||
|
||||
// 把異動類型與數量也拉到 snapshot
|
||||
$snapshot['type'] = $this->type;
|
||||
$snapshot['quantity'] = $this->quantity;
|
||||
$snapshot['reason'] = $this->reason;
|
||||
|
||||
// 替換使用者名稱
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
if (isset($data['user_id']) && is_numeric($data['user_id'])) {
|
||||
$data['user_id'] = \App\Modules\Core\Models\User::find($data['user_id'])?->name;
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,21 +36,23 @@ class InventoryTransferOrder extends Model
|
||||
if ($eventName === 'created') {
|
||||
$activity->description = 'created';
|
||||
} elseif ($eventName === 'updated') {
|
||||
// 如果屬性中有 status 且變更為 completed,將描述改為 posted
|
||||
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
|
||||
$activity->description = 'posted';
|
||||
$eventName = 'posted'; // 供後續快照邏輯判定
|
||||
$eventName = 'posted';
|
||||
} else {
|
||||
$activity->description = 'updated';
|
||||
}
|
||||
}
|
||||
|
||||
// 處理倉庫 ID 轉名稱
|
||||
// 處理 ID 轉名稱 (核心:支援 attributes 與 old 的自動轉換)
|
||||
$idToNameFields = [
|
||||
'from_warehouse_id' => 'fromWarehouse',
|
||||
'to_warehouse_id' => 'toWarehouse',
|
||||
'transit_warehouse_id' => 'transitWarehouse',
|
||||
'created_by' => 'createdBy',
|
||||
'posted_by' => 'postedBy',
|
||||
'dispatched_by' => 'dispatchedBy',
|
||||
'received_by' => 'receivedBy',
|
||||
];
|
||||
|
||||
foreach (['attributes', 'old'] as $part) {
|
||||
@@ -58,14 +60,20 @@ class InventoryTransferOrder extends Model
|
||||
foreach ($idToNameFields as $idField => $relation) {
|
||||
if (isset($properties[$part][$idField])) {
|
||||
$id = $properties[$part][$idField];
|
||||
$nameField = str_replace('_id', '_name', $idField);
|
||||
if (!$id) continue;
|
||||
|
||||
$nameField = str_replace('_id', '_name', $idField);
|
||||
$name = null;
|
||||
try {
|
||||
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";
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,14 +287,21 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
}
|
||||
|
||||
|
||||
private function generateCode(string $date)
|
||||
private function generateCode(string $date): string
|
||||
{
|
||||
// 使用 Cache Lock 防止併發時產生重複單號
|
||||
$lock = \Illuminate\Support\Facades\Cache::lock('gr_code_generation', 10);
|
||||
|
||||
if (!$lock->get()) {
|
||||
throw new \Exception('系統忙碌中,進貨單號生成失敗,請稍後再試');
|
||||
}
|
||||
|
||||
try {
|
||||
// Format: GR-YYYYMMDD-NN
|
||||
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
|
||||
|
||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||
->orderBy('id', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($last) {
|
||||
@@ -178,7 +310,12 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
$code = $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
|
||||
return $code;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
// 更新叫貨單狀態
|
||||
|
||||
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TraceabilityService
|
||||
{
|
||||
public function __construct(
|
||||
protected ProductionServiceInterface $productionService,
|
||||
protected ProcurementServiceInterface $procurementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 逆向溯源:從成品批號往前追溯用到的所有原料與廠商
|
||||
*
|
||||
* @param string $batchNumber 成品批號
|
||||
* @return array 樹狀結構資料
|
||||
*/
|
||||
public function traceBackward(string $batchNumber): array
|
||||
{
|
||||
// 取得基本庫存資訊以作為根節點參考
|
||||
$baseInventory = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->first();
|
||||
|
||||
// 定義根節點
|
||||
$rootNode = [
|
||||
'id' => 'batch_' . $batchNumber,
|
||||
'type' => 'target_batch',
|
||||
'label' => '查詢批號: ' . $batchNumber,
|
||||
'batch_number' => $batchNumber,
|
||||
'product_name' => $baseInventory?->product?->name,
|
||||
'spec' => $baseInventory?->product?->spec,
|
||||
'warehouse_name' => $baseInventory?->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 1. 尋找這個批號是不是生產出來的成品 (Production Order Output)
|
||||
// 透過 ProductionService 獲取,以落實模組解耦
|
||||
$productionOrders = $this->productionService->getProductionOrdersByOutputBatch($batchNumber);
|
||||
|
||||
foreach ($productionOrders as $po) {
|
||||
$poNode = [
|
||||
'id' => 'po_' . $po->id,
|
||||
'type' => 'production_order',
|
||||
'label' => '生產工單: ' . $po->code,
|
||||
'date' => $po->production_date instanceof \DateTimeInterface
|
||||
? $po->production_date->format('Y-m-d')
|
||||
: $po->production_date,
|
||||
'quantity' => $po->output_quantity,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 針對每一張工單,尋找它投料的原料批號
|
||||
foreach ($po->items as $item) {
|
||||
if (isset($item->inventory)) {
|
||||
$materialNode = $this->buildMaterialBackwardNode($item->inventory, $item);
|
||||
$poNode['children'][] = $materialNode;
|
||||
}
|
||||
}
|
||||
$rootNode['children'][] = $poNode;
|
||||
}
|
||||
|
||||
// 2. 如果這批號是直接採購進來的 (Goods Receipt)
|
||||
// 或者是為了補足直接查詢原料批號的場景
|
||||
$inventories = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->get();
|
||||
|
||||
foreach ($inventories as $inv) {
|
||||
// 尋找進貨單
|
||||
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->where('product_id', $inv->product_id)
|
||||
->get();
|
||||
|
||||
foreach ($grItems as $grItem) {
|
||||
$gr = $grItem->goodsReceipt;
|
||||
if ($gr) {
|
||||
$grNode = [
|
||||
'id' => 'gr_' . $gr->id . '_' . $inv->id,
|
||||
'type' => 'goods_receipt',
|
||||
'label' => '進貨單: ' . $gr->code,
|
||||
'date' => $gr->received_date instanceof \DateTimeInterface
|
||||
? $gr->received_date->format('Y-m-d')
|
||||
: $gr->received_date,
|
||||
'vendor_id' => $gr->vendor_id,
|
||||
'quantity' => $grItem->quantity,
|
||||
'product_name' => $grItem->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 避免重複加入
|
||||
$isDuplicate = false;
|
||||
foreach ($rootNode['children'] as $child) {
|
||||
if ($child['id'] === $grNode['id']) {
|
||||
$isDuplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$isDuplicate) {
|
||||
$rootNode['children'][] = $grNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 補充廠商名稱 (跨模組)
|
||||
$this->hydrateVendorNames($rootNode);
|
||||
|
||||
return $rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立原料的逆向溯源節點
|
||||
*/
|
||||
private function buildMaterialBackwardNode(Inventory $inventory, $poItem = null): array
|
||||
{
|
||||
$node = [
|
||||
'id' => 'inv_' . $inventory->id,
|
||||
'type' => 'material_batch',
|
||||
'label' => '原料批號: ' . $inventory->batch_number,
|
||||
'product_name' => $inventory->product?->name,
|
||||
'spec' => $inventory->product?->spec,
|
||||
'batch_number' => $inventory->batch_number,
|
||||
'quantity' => $poItem ? $poItem->quantity_used : null,
|
||||
'warehouse_name' => $inventory->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 繼續往下追溯該原料是怎麼來的 (進貨單)
|
||||
if ($inventory->batch_number) {
|
||||
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
|
||||
->where('batch_number', $inventory->batch_number)
|
||||
->where('product_id', $inventory->product_id)
|
||||
->get();
|
||||
|
||||
foreach ($grItems as $grItem) {
|
||||
$gr = $grItem->goodsReceipt;
|
||||
if ($gr) {
|
||||
$node['children'][] = [
|
||||
'id' => 'gr_' . $gr->id,
|
||||
'type' => 'goods_receipt',
|
||||
'label' => '進貨單: ' . $gr->code,
|
||||
'date' => $gr->received_date instanceof \DateTimeInterface
|
||||
? $gr->received_date->format('Y-m-d')
|
||||
: $gr->received_date,
|
||||
'vendor_id' => $gr->vendor_id,
|
||||
'quantity' => $grItem->quantity,
|
||||
'product_name' => $grItem->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 順向追蹤:從原料批號往後追查被用在哪些成品及去向
|
||||
*
|
||||
* @param string $batchNumber 原料批號
|
||||
* @return array 樹狀結構資料
|
||||
*/
|
||||
public function traceForward(string $batchNumber): array
|
||||
{
|
||||
$baseInventory = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $batchNumber)
|
||||
->first();
|
||||
|
||||
$rootNode = [
|
||||
'id' => 'batch_' . $batchNumber,
|
||||
'type' => 'source_batch',
|
||||
'label' => '查詢批號: ' . $batchNumber,
|
||||
'batch_number' => $batchNumber,
|
||||
'product_name' => $baseInventory?->product?->name,
|
||||
'spec' => $baseInventory?->product?->spec,
|
||||
'warehouse_name' => $baseInventory?->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 1. 尋找這個批號被哪些工單使用了
|
||||
$inventories = Inventory::with(['product', 'warehouse'])->where('batch_number', $batchNumber)->get();
|
||||
|
||||
foreach ($inventories as $inv) {
|
||||
// 透過 ProductionService 獲取,以落實模組解耦
|
||||
$poItems = $this->productionService->getProductionOrderItemsByInventoryId($inv->id, ['productionOrder']);
|
||||
|
||||
foreach ($poItems as $item) {
|
||||
$po = $item->productionOrder;
|
||||
if ($po) {
|
||||
$poNode = [
|
||||
'id' => 'po_' . $po->id,
|
||||
'type' => 'production_order',
|
||||
'label' => '投入工單: ' . $po->code,
|
||||
'date' => $po->production_date instanceof \DateTimeInterface
|
||||
? $po->production_date->format('Y-m-d')
|
||||
: $po->production_date,
|
||||
'quantity' => $item->quantity_used,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 該工單產出的成品批號
|
||||
if ($po->output_batch_number) {
|
||||
$outputInventory = Inventory::with(['product', 'warehouse'])
|
||||
->where('batch_number', $po->output_batch_number)
|
||||
->first();
|
||||
|
||||
$outputNode = [
|
||||
'id' => 'output_batch_' . $po->output_batch_number,
|
||||
'type' => 'target_batch',
|
||||
'label' => '產出成品: ' . $po->output_batch_number,
|
||||
'batch_number' => $po->output_batch_number,
|
||||
'quantity' => $po->output_quantity,
|
||||
'product_name' => $outputInventory?->product?->name,
|
||||
'spec' => $outputInventory?->product?->spec,
|
||||
'warehouse_name' => $outputInventory?->warehouse?->name,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 追蹤成品的出庫紀錄 (銷貨、領料等)
|
||||
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
|
||||
->whereHas('inventory', function ($q) use ($po) {
|
||||
$q->where('batch_number', $po->output_batch_number);
|
||||
})
|
||||
->where('quantity', '<', 0) // 出庫
|
||||
->get();
|
||||
|
||||
foreach ($outTransactions as $txn) {
|
||||
$refType = class_basename($txn->reference_type);
|
||||
$outputNode['children'][] = [
|
||||
'id' => 'txn_' . $txn->id,
|
||||
'type' => 'outbound_transaction',
|
||||
'label' => '出庫單據: ' . $refType . ' #' . $txn->reference_id,
|
||||
'date' => $txn->actual_time,
|
||||
'quantity' => abs($txn->quantity),
|
||||
'product_name' => $txn->inventory?->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
}
|
||||
|
||||
$poNode['children'][] = $outputNode;
|
||||
}
|
||||
|
||||
$rootNode['children'][] = $poNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果這個批號自己本身就有出庫紀錄 (不是被生產掉,而是直接被領走或賣掉)
|
||||
foreach ($inventories as $inv) {
|
||||
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
|
||||
->where('inventory_id', $inv->id)
|
||||
->where('quantity', '<', 0)
|
||||
->get();
|
||||
|
||||
foreach ($outTransactions as $txn) {
|
||||
// 如果是生產工單領料,上面已經處理過,這裡濾掉
|
||||
if ($txn->reference_type && str_contains($txn->reference_type, 'ProductionOrder')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$refType = $txn->reference_type ? class_basename($txn->reference_type) : '未知';
|
||||
$rootNode['children'][] = [
|
||||
'id' => 'txn_direct_' . $txn->id,
|
||||
'type' => 'outbound_transaction',
|
||||
'label' => '直接出庫: ' . $refType . ' #' . $txn->reference_id,
|
||||
'date' => $txn->actual_time,
|
||||
'quantity' => abs($txn->quantity),
|
||||
'product_name' => $txn->inventory?->product?->name,
|
||||
'children' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 水和廠商名稱 (跨模組)
|
||||
*/
|
||||
private function hydrateVendorNames(array &$node): void
|
||||
{
|
||||
$vendorIds = [];
|
||||
$this->collectVendorIds($node, $vendorIds);
|
||||
|
||||
if (empty($vendorIds)) return;
|
||||
|
||||
$vendors = $this->procurementService->getVendorsByIds(array_unique($vendorIds))->keyBy('id');
|
||||
|
||||
$this->applyVendorNames($node, $vendors);
|
||||
}
|
||||
|
||||
private function collectVendorIds(array $node, array &$ids): void
|
||||
{
|
||||
if (isset($node['vendor_id'])) {
|
||||
$ids[] = $node['vendor_id'];
|
||||
}
|
||||
if (!empty($node['children'])) {
|
||||
foreach ($node['children'] as $child) {
|
||||
$this->collectVendorIds($child, $ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function applyVendorNames(array &$node, Collection $vendors): void
|
||||
{
|
||||
if (isset($node['vendor_id']) && $vendors->has($node['vendor_id'])) {
|
||||
$vendor = $vendors->get($node['vendor_id']);
|
||||
$node['label'] .= ' (廠商: ' . $vendor->name . ')';
|
||||
}
|
||||
if (!empty($node['children'])) {
|
||||
foreach ($node['children'] as &$child) {
|
||||
$this->applyVendorNames($child, $vendors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,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;
|
||||
$sourceBefore = (float) $sourceInventory->quantity;
|
||||
|
||||
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
||||
// 釋放草稿階段預扣的庫存
|
||||
$sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity);
|
||||
$sourceInventory->saveQuietly();
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
$sourceInventory->save();
|
||||
$item->update(['snapshot_quantity' => $sourceBefore]);
|
||||
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => $outType,
|
||||
'quantity' => -$item->quantity,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
// 2. 處理目的倉/在途倉 (增加)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $targetWarehouseId,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'location' => $hasTransit ? null : ($item->position ?? null),
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'total_value' => 0,
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
// 委託 InventoryService 處理扣庫與 Transaction
|
||||
$this->inventoryService->decreaseInventoryQuantity(
|
||||
$sourceInventory->id,
|
||||
$item->quantity,
|
||||
"調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
||||
InventoryTransferOrder::class,
|
||||
$order->id
|
||||
);
|
||||
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
||||
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
||||
// 2. 處理目的倉/在途倉 (增加)
|
||||
// 獲取目的倉異動前的庫存數(若無則為 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;
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
// 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,
|
||||
]
|
||||
// 委託 InventoryService 處理扣庫與 Transaction
|
||||
$this->inventoryService->decreaseInventoryQuantity(
|
||||
$transitInventory->id,
|
||||
$item->quantity,
|
||||
"調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
|
||||
InventoryTransferOrder::class,
|
||||
$order->id
|
||||
);
|
||||
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $transitInventory->unit_cost;
|
||||
}
|
||||
$transitAfter = $transitBefore - (float) $item->quantity;
|
||||
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
||||
// 2. 目的倉增加
|
||||
$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;
|
||||
|
||||
$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([
|
||||
|
||||
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',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
],
|
||||
'old' => [
|
||||
'status' => $oldStatus,
|
||||
]
|
||||
])
|
||||
->log('voided');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
$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(30))
|
||||
->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 * 30) / $totalSales30d : 0;
|
||||
$avgTurnoverDays = $totalSales30d > 0 ? ($totalStock * $analysisDays) / $totalSales30d : 0;
|
||||
|
||||
return [
|
||||
'total_stock_value' => $totalValue,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. 手動注入倉庫與使用者資料
|
||||
@@ -184,6 +188,14 @@ class PurchaseOrderController extends Controller
|
||||
'tax_amount' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
try {
|
||||
// 使用 Cache Lock 防止併發時產生重複單號
|
||||
$lock = \Illuminate\Support\Facades\Cache::lock('po_code_generation', 10);
|
||||
|
||||
if (!$lock->get()) {
|
||||
return back()->withErrors(['error' => '系統忙碌中,請稍後再試']);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
@@ -191,12 +203,10 @@ class PurchaseOrderController extends Controller
|
||||
$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 {
|
||||
@@ -216,16 +226,18 @@ class PurchaseOrderController extends Controller
|
||||
// 確保有一個有效的使用者 ID
|
||||
$userId = auth()->id();
|
||||
if (!$userId) {
|
||||
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
|
||||
$user = $this->coreService->ensureSystemUserExists();
|
||||
$userId = $user->id;
|
||||
}
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
// 靜默建立以抑制自動日誌(後續手動發送含品項明細的日誌)
|
||||
$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'], // 新增
|
||||
'order_date' => $validated['order_date'],
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
@@ -235,6 +247,12 @@ class PurchaseOrderController extends Controller
|
||||
'invoice_date' => $validated['invoice_date'] ?? null,
|
||||
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||
]);
|
||||
$order->saveQuietly();
|
||||
|
||||
// 建立品項並收集 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) {
|
||||
// 反算單價
|
||||
@@ -247,9 +265,47 @@ class PurchaseOrderController extends Controller
|
||||
'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');
|
||||
|
||||
251
app/Modules/Procurement/Controllers/PurchaseReturnController.php
Normal file
251
app/Modules/Procurement/Controllers/PurchaseReturnController.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
// 水和倉庫與使用者
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '供貨商品已更新');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? [];
|
||||
// 🚩 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
// 1. Snapshot 快照
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['po_number'] = $this->code;
|
||||
|
||||
if ($this->vendor) {
|
||||
$snapshot['vendor_name'] = $this->vendor->name;
|
||||
$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;
|
||||
}
|
||||
// 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.
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'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
|
||||
|
||||
93
app/Modules/Procurement/Models/PurchaseReturn.php
Normal file
93
app/Modules/Procurement/Models/PurchaseReturn.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Modules/Procurement/Models/PurchaseReturnItem.php
Normal file
33
app/Modules/Procurement/Models/PurchaseReturnItem.php
Normal 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.
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
210
app/Modules/Procurement/Services/PurchaseReturnService.php
Normal file
210
app/Modules/Procurement/Services/PurchaseReturnService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (跨模組)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/Modules/Production/Services/ProductionService.php
Normal file
29
app/Modules/Production/Services/ProductionService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
11
app/Modules/Sales/Contracts/SalesServiceInterface.php
Normal file
11
app/Modules/Sales/Contracts/SalesServiceInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
15
app/Modules/Sales/SalesServiceProvider.php
Normal file
15
app/Modules/Sales/SalesServiceProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
63
app/Modules/Sales/Services/SalesService.php
Normal file
63
app/Modules/Sales/Services/SalesService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
68
database/seeders/SystemSettingSeeder.php
Normal file
68
database/seeders/SystemSettingSeeder.php
Normal 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
29
nginx/local-proxy.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
public/images/manual/overview_and_login.webp
Normal file
BIN
public/images/manual/overview_and_login.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */}
|
||||
{activity.description !== getEventLabel(activity.event) &&
|
||||
activity.description !== 'created' && activity.description !== 'updated' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-6 pb-2 -mt-2">
|
||||
<ActivityIcon className="w-4 h-4 text-gray-400" />
|
||||
<span>{activity.description}</span>
|
||||
<span className="text-sm text-gray-500">{activity.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
{/* 1. 處理物件格式的 items_diff (現有的採購、盤點、調撥初始紀錄格式) */}
|
||||
{!Array.isArray(activity.properties?.items_diff) && (
|
||||
<>
|
||||
{/* 更新項目 */}
|
||||
{activity.properties.items_diff.updated?.map((item: any, idx: number) => (
|
||||
{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">
|
||||
{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>
|
||||
<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?.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.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}
|
||||
);
|
||||
}) || null}
|
||||
|
||||
{/* 新增項目 */}
|
||||
{activity.properties.items_diff.added?.map((item: any, idx: number) => (
|
||||
{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">
|
||||
{item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''}
|
||||
{item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''}
|
||||
{item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''}
|
||||
<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}
|
||||
);
|
||||
}) || null}
|
||||
|
||||
{/* 移除項目 */}
|
||||
{activity.properties.items_diff.removed?.map((item: any, idx: number) => (
|
||||
{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">
|
||||
原紀錄: {item.quantity} {item.unit_name}
|
||||
原數量: {qty} {item.unit_name || item.old?.unit_name || ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)) || null}
|
||||
);
|
||||
}) || 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-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.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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent >
|
||||
</Dialog >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
100
resources/js/Components/PurchaseReturn/PurchaseReturnActions.tsx
Normal file
100
resources/js/Components/PurchaseReturn/PurchaseReturnActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
213
resources/js/Components/PurchaseReturn/PurchaseReturnTable.tsx
Normal file
213
resources/js/Components/PurchaseReturn/PurchaseReturnTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
139
resources/js/Components/Vendor/SupplyProductList.tsx
vendored
139
resources/js/Components/Vendor/SupplyProductList.tsx
vendored
@@ -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,89 +10,128 @@ 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>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
<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 className="text-right">
|
||||
{product.lastPrice ? (
|
||||
<span>
|
||||
${product.lastPrice.toLocaleString()} / {product.baseUnit || product.unit || "單位"}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
|
||||
<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">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<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"
|
||||
className="button-outlined-error h-10 w-10 p-0"
|
||||
title="移除項目"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user