Compare commits
28 Commits
feature/mo
...
46753cc3bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 46753cc3bc | |||
| 7f726e80bd | |||
| 8bc95db43d | |||
| 95a1763d04 | |||
| 90cb7a82de | |||
| bbb2c4c4a3 | |||
| 8cb95e1a56 | |||
| fc59c86305 | |||
| b613cdb796 | |||
| b1745555cc | |||
| 1833ca192d | |||
| e5edad4fd0 | |||
| 852370cfe0 | |||
| 965418077b | |||
| c3af92c85c | |||
| cca49b5fe8 | |||
| d4cef2cd84 | |||
| 4c959efc8b | |||
| 95d8dc2e84 | |||
| a7c445bd3f | |||
| 293358df62 | |||
| 1ed3d6a29d | |||
| 646435f87a | |||
| f10c31abd0 | |||
| 046e0a028b | |||
| ce0a7b3409 | |||
| 084bbc9f53 | |||
| 3af4a1e298 |
@@ -50,7 +50,16 @@ trigger: always_on
|
||||
* Routes: `kebab-case` (小寫橫線分隔)
|
||||
* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||
|
||||
## 6. AI 協作規則 (給 Antigravity AI)
|
||||
## 6. 嚴格模組化通訊規範 (Strict Modular Communication)
|
||||
為了確保系統的可維護性與獨立性,所有模組必須遵守以下「實體解耦」規範:
|
||||
|
||||
* **禁止跨模組 Eloquent 關聯**:禁止在 Model 中定義指向其他模組的 `belongsTo`, `hasMany` 等關聯。
|
||||
* **介面化通訊 (Contracts)**:模組間的資料交換與功能調用必須透過 `app/Modules/{ModuleName}/Contracts/` 下定義的介面進行。
|
||||
* **禁止跨模組 Model 引用**:Controller 與 Service 禁止 `use` 其他模組的 Model (除非是該模組自身的 Contracts)。
|
||||
* **手動資料水和 (Manual Hydration)**:若頁面需要顯示跨模組資料(例:訂單顯示使用者名稱),Controller 應透過 Service 獲取基本資料,再手動組合成前端所需的 JSON/Props 結構。
|
||||
* **資料一致性**:跨模組的資料操作應由各模組的 Service 處理其內部的 transaction 完整性。
|
||||
|
||||
## 7. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
@@ -58,7 +67,7 @@ trigger: always_on
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 7. 運行機制 (Docker / Sail)
|
||||
## 8. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
|
||||
@@ -123,8 +123,8 @@ tooltip
|
||||
// ✅ 成功操作
|
||||
<Button className="button-filled-success">確認</Button>
|
||||
|
||||
// ✅ 資訊操作
|
||||
<Button className="button-filled-info">查看詳情</Button>
|
||||
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
|
||||
<Button className="button-filled-info">系統資訊</Button>
|
||||
|
||||
// ✅ 警告操作
|
||||
<Button className="button-filled-warning">警告</Button>
|
||||
@@ -177,6 +177,23 @@ tooltip
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列檢視按鈕
|
||||
|
||||
```tsx
|
||||
<Can permission="resource.view">
|
||||
<Link href={route('resource.show', item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="檢視"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
```
|
||||
|
||||
#### 表格操作列編輯按鈕
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -100,6 +100,7 @@ jobs:
|
||||
npm run build &&
|
||||
|
||||
# 3. Laravel 初始化與優化
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan optimize:clear &&
|
||||
@@ -130,6 +131,7 @@ jobs:
|
||||
--exclude='.env' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='vendor' \
|
||||
--exclude='storage' \
|
||||
--exclude='public/build' \
|
||||
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
||||
./ root@erp.koori.tw:/var/www/star-erp/
|
||||
@@ -169,7 +171,6 @@ jobs:
|
||||
script: |
|
||||
cd /var/www/star-erp
|
||||
chown -R 1000:1000 .
|
||||
|
||||
# 檢查是否需要重建
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
@@ -192,6 +193,7 @@ jobs:
|
||||
npm install &&
|
||||
npm run build
|
||||
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
|
||||
@@ -13,7 +13,7 @@ Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind
|
||||
|
||||
## 📂 系統功能詳細說明
|
||||
|
||||
### 🌳 系統功能架構樹 (含 2.0 升級規劃)
|
||||
### 🌳 預計系統功能架構樹 (含 2.0 升級規劃)
|
||||
```text
|
||||
Star ERP
|
||||
├── 🏠 儀表板 (Dashboard)
|
||||
|
||||
@@ -56,6 +56,11 @@ class TenantController extends Controller
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'] ?? null,
|
||||
'is_active' => true,
|
||||
'branding' => [
|
||||
'logo_path' => 'defaults/logo.png', // 預設 Logo 路徑
|
||||
'login_background_path' => 'defaults/login_bg.jpg', // 預設登入背景
|
||||
'primary_color' => '#4F46E5', // 預設主色系 (Indigo-600)
|
||||
],
|
||||
]);
|
||||
|
||||
// 綁定網域(如果沒有輸入,使用預設網域)
|
||||
|
||||
@@ -37,8 +37,15 @@ class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$tenant = tenancy()->tenant;
|
||||
$appName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
|
||||
|
||||
// 分享給 Blade View (給 app.blade.php 使用)
|
||||
\Illuminate\Support\Facades\View::share('appName', $appName);
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'appName' => $appName,
|
||||
'auth' => [
|
||||
'user' => $user ? [
|
||||
'id' => $user->id,
|
||||
@@ -58,7 +65,12 @@ class HandleInertiaRequests extends Middleware
|
||||
'branding' => function () {
|
||||
$tenant = tenancy()->tenant;
|
||||
if (!$tenant) {
|
||||
return null;
|
||||
// 中央後台預設 Branding
|
||||
return [
|
||||
'logo_url' => \Storage::url('defaults/logo.png'), // 中央後台也使用預設 Logo
|
||||
'primary_color' => '#4F46E5',
|
||||
'text_color' => '#1a1a1a',
|
||||
];
|
||||
}
|
||||
|
||||
$logoUrl = null;
|
||||
|
||||
@@ -23,6 +23,11 @@ class ActivityLogController extends Controller
|
||||
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
|
||||
'App\Modules\Inventory\Models\Inventory' => '庫存',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
|
||||
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
|
||||
'App\Modules\Production\Models\Recipe' => '生產配方',
|
||||
'App\Modules\Production\Models\RecipeItem' => '配方品項',
|
||||
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
@@ -44,15 +45,14 @@ class LoginController extends Controller
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
// [Hack] Demo 環境特殊規則
|
||||
$demoPort = config('tenancy.demo_tenant_port');
|
||||
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
|
||||
return redirect()->intended(route('landlord.dashboard'));
|
||||
return Inertia::location(route('landlord.dashboard'));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
return Inertia::location(route('dashboard'));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
@@ -71,6 +71,10 @@ class LoginController extends Controller
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// 強制清除 Session Cookie (對付 HTTPS/Proxy 環境下的殘留問題)
|
||||
$sessionCookieName = config('session.cookie');
|
||||
Cookie::queue(Cookie::forget($sessionCookieName));
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +178,12 @@ class RoleController extends Controller
|
||||
'inventory' => '庫存管理',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'recipes' => '配方管理',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'system' => '系統管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
];
|
||||
|
||||
208
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
208
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\AdjustService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class AdjustDocController extends Controller
|
||||
{
|
||||
protected $adjustService;
|
||||
|
||||
public function __construct(AdjustService $adjustService)
|
||||
{
|
||||
$this->adjustService = $adjustService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryAdjustDoc::query()
|
||||
->with(['createdBy', 'postedBy', 'warehouse']);
|
||||
|
||||
// 搜尋
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('doc_no', 'like', "%{$search}%")
|
||||
->orWhere('reason', 'like', "%{$search}%")
|
||||
->orWhere('remarks', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 15);
|
||||
$docs = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($doc) {
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'status' => $doc->status,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'reason' => $doc->reason,
|
||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||
'posted_at' => $doc->posted_at ? $doc->posted_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'remarks' => $doc->remarks,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// 模式 1: 從盤點單建立
|
||||
if ($request->filled('count_doc_id')) {
|
||||
$countDoc = InventoryCountDoc::findOrFail($request->count_doc_id);
|
||||
|
||||
// 檢查是否已存在對應的盤調單 (避免重複建立)
|
||||
if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) {
|
||||
return redirect()->back()->with('error', '此盤點單已建立過盤調單');
|
||||
}
|
||||
|
||||
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
|
||||
return redirect()->route('inventory.adjust.show', [$doc->id])
|
||||
->with('success', '已從盤點單生成盤調單');
|
||||
}
|
||||
|
||||
// 模式 2: 一般手動調整 (保留原始邏輯但更新訊息)
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required',
|
||||
'reason' => 'required|string',
|
||||
'remarks' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$doc = $this->adjustService->createDoc(
|
||||
$validated['warehouse_id'],
|
||||
$validated['reason'],
|
||||
$validated['remarks'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return redirect()->route('inventory.adjust.show', [$doc->id])
|
||||
->with('success', '已建立盤調單');
|
||||
}
|
||||
|
||||
/**
|
||||
* API: 獲取可盤調的已完成盤點單 (支援掃描單號)
|
||||
*/
|
||||
public function getPendingCounts(Request $request)
|
||||
{
|
||||
$query = InventoryCountDoc::where('status', 'completed')
|
||||
->whereNotExists(function ($query) {
|
||||
$query->select(DB::raw(1))
|
||||
->from('inventory_adjust_docs')
|
||||
->whereColumn('inventory_adjust_docs.count_doc_id', 'inventory_count_docs.id');
|
||||
});
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where('doc_no', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$counts = $query->limit(10)->get()->map(function($c) {
|
||||
return [
|
||||
'id' => (string)$c->id,
|
||||
'doc_no' => $c->doc_no,
|
||||
'warehouse_name' => $c->warehouse->name,
|
||||
'completed_at' => $c->completed_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($counts);
|
||||
}
|
||||
|
||||
public function show(InventoryAdjustDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse']);
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_id' => (string) $doc->warehouse_id,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'status' => $doc->status,
|
||||
'reason' => $doc->reason,
|
||||
'remarks' => $doc->remarks,
|
||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'items' => $doc->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_id' => (string) $item->product_id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'qty_before' => (float) $item->qty_before,
|
||||
'adjust_qty' => (float) $item->adjust_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Show', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryAdjustDoc $doc)
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
// 提交 (items 更新 或 過帳)
|
||||
if ($request->input('action') === 'post') {
|
||||
$this->adjustService->post($doc, auth()->id());
|
||||
return redirect()->route('inventory.adjust.index')
|
||||
->with('success', '調整單已過帳生效');
|
||||
}
|
||||
|
||||
// 僅儲存資料
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.adjust_qty' => 'required|numeric', // 可以是負數
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($request->has('items')) {
|
||||
$this->adjustService->updateItems($doc, $validated['items']);
|
||||
}
|
||||
|
||||
// 更新表頭
|
||||
$doc->update($request->only(['reason', 'remarks']));
|
||||
|
||||
return redirect()->back()->with('success', '儲存成功');
|
||||
}
|
||||
|
||||
public function destroy(InventoryAdjustDoc $doc)
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
return redirect()->route('inventory.adjust.index')
|
||||
->with('success', '調整單已刪除');
|
||||
}
|
||||
}
|
||||
158
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
158
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\CountService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CountDocController extends Controller
|
||||
{
|
||||
protected $countService;
|
||||
|
||||
public function __construct(CountService $countService)
|
||||
{
|
||||
$this->countService = $countService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryCountDoc::query()
|
||||
->with(['createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('doc_no', 'like', "%{$search}%")
|
||||
->orWhere('remarks', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 15;
|
||||
}
|
||||
|
||||
$docs = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($doc) {
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'status' => $doc->status,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-',
|
||||
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'remarks' => $doc->remarks,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Count/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'remarks' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$doc = $this->countService->createDoc(
|
||||
$validated['warehouse_id'],
|
||||
$validated['remarks'] ?? null,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 自動執行快照
|
||||
$this->countService->snapshot($doc);
|
||||
|
||||
return redirect()->route('inventory.count.show', [$doc->id])
|
||||
->with('success', '已建立盤點單並完成庫存快照');
|
||||
}
|
||||
|
||||
public function show(InventoryCountDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_id' => (string) $doc->warehouse_id,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'status' => $doc->status,
|
||||
'remarks' => $doc->remarks,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'items' => $doc->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'system_qty' => (float) $item->system_qty,
|
||||
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
|
||||
'diff_qty' => (float) $item->diff_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Count/Show', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '此盤點單已完成,無法修改');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.id' => 'required|exists:inventory_count_items,id',
|
||||
'items.*.counted_qty' => 'nullable|numeric|min:0',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if (isset($validated['items'])) {
|
||||
$this->countService->updateCount($doc, $validated['items']);
|
||||
}
|
||||
|
||||
// 如果是按了 "完成盤點"
|
||||
if ($request->input('action') === 'complete') {
|
||||
$this->countService->complete($doc, auth()->id());
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點單已完成');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '暫存成功');
|
||||
}
|
||||
|
||||
public function destroy(InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
|
||||
}
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點單已刪除');
|
||||
}
|
||||
}
|
||||
248
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
248
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Services\GoodsReceiptService;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
protected $goodsReceiptService;
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(
|
||||
GoodsReceiptService $goodsReceiptService,
|
||||
InventoryService $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
) {
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = GoodsReceipt::query()
|
||||
->select(['id', 'code', 'type', 'warehouse_id', 'vendor_id', 'received_date', 'status', 'created_at'])
|
||||
->with(['warehouse'])
|
||||
->withSum('items', 'total_amount');
|
||||
|
||||
// 關鍵字搜尋(單號)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where('code', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->filled('status') && $request->input('status') !== 'all') {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
// 倉庫篩選
|
||||
if ($request->filled('warehouse_id') && $request->input('warehouse_id') !== 'all') {
|
||||
$query->where('warehouse_id', $request->input('warehouse_id'));
|
||||
}
|
||||
|
||||
// 日期範圍篩選
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('received_date', '>=', $request->input('date_start'));
|
||||
}
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('received_date', '<=', $request->input('date_end'));
|
||||
}
|
||||
|
||||
// 每頁筆數
|
||||
$perPage = $request->input('per_page', 10);
|
||||
|
||||
$receipts = $query->orderBy('created_at', 'desc')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
// Manual Hydration for Vendors (Cross-Module)
|
||||
$vendorIds = collect($receipts->items())->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendors = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
|
||||
|
||||
$receipts->getCollection()->transform(function ($receipt) use ($vendors) {
|
||||
$receipt->vendor = $vendors->get($receipt->vendor_id);
|
||||
return $receipt;
|
||||
});
|
||||
|
||||
// 取得倉庫列表用於篩選
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Index', [
|
||||
'receipts' => $receipts,
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'per_page']),
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$receipt = GoodsReceipt::with([
|
||||
'warehouse',
|
||||
'items.product.category',
|
||||
'items.product.baseUnit'
|
||||
])->findOrFail($id);
|
||||
|
||||
// Manual Hydration for Vendor (Cross-Module)
|
||||
if ($receipt->vendor_id) {
|
||||
$receipt->vendor = $this->procurementService->getVendorsByIds([$receipt->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 手動計算統計資訊 (如果 Model 沒有定義對應的 Attribute)
|
||||
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Show', [
|
||||
'receipt' => $receipt
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// 取得待進貨的採購單列表(用於標準採購類型選擇)
|
||||
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
|
||||
|
||||
// 提取所有產品 ID 以便跨模組水和資料
|
||||
$productIds = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// 處理採購單資料,計算剩餘可收貨數量
|
||||
$formattedPOs = $pendingPOs->map(function ($po) use ($products) {
|
||||
return [
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'vendor_id' => $po->vendor_id,
|
||||
'vendor_name' => $po->vendor?->name ?? '',
|
||||
'warehouse_id' => $po->warehouse_id,
|
||||
'order_date' => $po->order_date,
|
||||
'items' => $po->items->map(function ($item) use ($products) {
|
||||
$product = $products->get($item->product_id);
|
||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $product?->baseUnit?->name ?? '個',
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unit_price' => $item->unit_price,
|
||||
];
|
||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||
];
|
||||
})->filter(fn($po) => $po['items']->count() > 0)->values();
|
||||
|
||||
// 取得所有廠商列表(用於雜項入庫/其他類型選擇)
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Create', [
|
||||
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||
'pendingPurchaseOrders' => $formattedPOs,
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'type' => 'required|in:standard,miscellaneous,other',
|
||||
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
|
||||
// Vendor ID is required if standard, but optional/nullable for misc/other?
|
||||
// Stick to existing logic: if standard, we infer vendor from PO usually, or frontend sends it.
|
||||
// For now let's make vendor_id optional for misc/other or user must select one?
|
||||
// "雜項入庫" might not have a vendor. Let's make it nullable.
|
||||
'vendor_id' => 'nullable|integer',
|
||||
'received_date' => 'required|date',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|integer|exists:products,id',
|
||||
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
|
||||
'items.*.quantity_received' => 'required|numeric|min:0',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.expiry_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$this->goodsReceiptService->store($validated);
|
||||
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨單已建立');
|
||||
}
|
||||
|
||||
// API to search POs
|
||||
public function searchPOs(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$pos = $this->procurementService->searchPendingPurchaseOrders($search);
|
||||
|
||||
return response()->json($pos);
|
||||
}
|
||||
|
||||
// API to search Products for Manual Entry
|
||||
public function searchProducts(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$products = $this->inventoryService->getProductsByName($search);
|
||||
|
||||
// Format for frontend
|
||||
$mapped = $products->map(function($product) {
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
|
||||
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($mapped);
|
||||
}
|
||||
|
||||
// API to search Vendors
|
||||
public function searchVendors(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$vendors = $this->procurementService->searchVendors($search);
|
||||
|
||||
return response()->json($vendors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除進貨單
|
||||
*/
|
||||
public function destroy(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
// 只有有權限的人可以刪除
|
||||
if (!auth()->user()->can('goods_receipts.delete')) {
|
||||
return redirect()->back()->with('error', '您沒有權限刪除進貨單');
|
||||
}
|
||||
|
||||
// 簡單刪除邏輯:刪除進貨單(品項由資料庫級聯刪除或手動處理)
|
||||
// 注意:實務上可能需要處理已入庫的庫存回滾,但在這個簡易 ERP 中通常是行政刪除
|
||||
$goodsReceipt->delete();
|
||||
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
@@ -482,7 +483,60 @@ class InventoryController extends Controller
|
||||
$productId = $request->query('productId');
|
||||
|
||||
if ($productId) {
|
||||
// ... (略) ...
|
||||
$product = Product::findOrFail($productId);
|
||||
// 取得該倉庫中該商品的所有批號 ID
|
||||
$inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
$transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
|
||||
->with('inventory') // 需要批號資訊
|
||||
->orderBy('actual_time', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
// 手動 Hydrate 使用者資料
|
||||
$userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
// 計算商品在該倉庫的總量(不分批號)
|
||||
$currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
$transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
|
||||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||
$balanceAfter = $currentRunningTotal;
|
||||
|
||||
// 為下一筆(較舊的)紀錄更新 Running Total
|
||||
$currentRunningTotal -= (float) $tx->quantity;
|
||||
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
|
||||
];
|
||||
});
|
||||
|
||||
// 重新計算目前的總量(用於 Header 顯示,確保一致性)
|
||||
$totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
return Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => null, // 跨批號查詢沒有單一 ID
|
||||
'productName' => $product->name,
|
||||
'productCode' => $product->code,
|
||||
'batchNumber' => '所有批號',
|
||||
'quantity' => (float) $totalQuantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
|
||||
if ($inventoryId) {
|
||||
|
||||
@@ -3,135 +3,171 @@
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\TransferService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TransferOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 儲存撥補單(建立調撥單並執行庫存轉移)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
protected $transferService;
|
||||
|
||||
public function __construct(TransferService $transferService)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'sourceWarehouseId' => 'required|exists:warehouses,id',
|
||||
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
|
||||
'productId' => 'required|exists:products,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'transferDate' => 'required|date',
|
||||
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
|
||||
'notes' => 'nullable|string',
|
||||
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated) {
|
||||
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||
->where('product_id', $validated['productId'])
|
||||
->where('batch_number', $validated['batchNumber'])
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => ['來源倉庫指定批號庫存不足'],
|
||||
]);
|
||||
$this->transferService = $transferService;
|
||||
}
|
||||
|
||||
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $validated['targetWarehouseId'],
|
||||
'product_id' => $validated['productId'],
|
||||
'batch_number' => $validated['batchNumber'],
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'total_value' => 0,
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
);
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryTransferOrder::query()
|
||||
->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
|
||||
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
|
||||
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
|
||||
|
||||
// 3. 執行庫存轉移 (扣除來源)
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $validated['quantity'];
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源異動
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '撥補出庫',
|
||||
'quantity' => -$validated['quantity'],
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 4. 執行庫存轉移 (增加目標)
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $validated['quantity'];
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
|
||||
// 確保目標庫存也有成本 (如果是繼承來的)
|
||||
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
|
||||
$targetInventory->save();
|
||||
|
||||
// 記錄目標異動
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '撥補入庫',
|
||||
'quantity' => $validated['quantity'],
|
||||
'unit_cost' => $targetInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
|
||||
|
||||
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
|
||||
// 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('from_warehouse_id', $request->warehouse_id)
|
||||
->orWhere('to_warehouse_id', $request->warehouse_id);
|
||||
});
|
||||
}
|
||||
|
||||
$orders = $query->orderByDesc('created_at')
|
||||
->paginate(15)
|
||||
->through(function ($order) {
|
||||
return [
|
||||
'id' => (string) $order->id,
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'status' => $order->status,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $order->createdBy?->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Index', [
|
||||
'orders' => $orders,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from_warehouse_id' => 'required|exists:warehouses,id',
|
||||
'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id',
|
||||
'remarks' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$order = $this->transferService->createOrder(
|
||||
$validated['from_warehouse_id'],
|
||||
$validated['to_warehouse_id'],
|
||||
$validated['remarks'] ?? null,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return redirect()->route('inventory.transfer.show', [$order->id])
|
||||
->with('success', '已建立調撥單');
|
||||
}
|
||||
|
||||
public function show(InventoryTransferOrder $order)
|
||||
{
|
||||
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
|
||||
$orderData = [
|
||||
'id' => (string) $order->id,
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_id' => (string) $order->from_warehouse_id,
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'to_warehouse_id' => (string) $order->to_warehouse_id,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'status' => $order->status,
|
||||
'remarks' => $order->remarks,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $order->createdBy?->name,
|
||||
'items' => $order->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_id' => (string) $item->product_id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'quantity' => (float) $item->quantity,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Show', [
|
||||
'order' => $orderData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryTransferOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
if ($request->input('action') === 'post') {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已過帳完成');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($request->has('items')) {
|
||||
$this->transferService->updateItems($order, $validated['items']);
|
||||
}
|
||||
|
||||
$order->update($request->only(['remarks']));
|
||||
|
||||
return redirect()->back()->with('success', '儲存成功');
|
||||
}
|
||||
|
||||
public function destroy(InventoryTransferOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
|
||||
}
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取特定倉庫的庫存列表 (API)
|
||||
* 獲取特定倉庫的庫存列表 (API) - 保留給前端選擇商品用
|
||||
*/
|
||||
public function getWarehouseInventories(Warehouse $warehouse)
|
||||
{
|
||||
$inventories = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'product.category'])
|
||||
->where('quantity', '>', 0) // 只回傳有庫存的
|
||||
->where('quantity', '>', 0)
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'product_id' => (string) $inv->product_id,
|
||||
'product_name' => $inv->product->name,
|
||||
'product_code' => $inv->product->code, // Added code
|
||||
'batch_number' => $inv->batch_number,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'unit_cost' => (float) $inv->unit_cost, // 新增
|
||||
'total_value' => (float) $inv->total_value, // 新增
|
||||
'unit_cost' => (float) $inv->unit_cost,
|
||||
'unit_name' => $inv->product->baseUnit?->name ?? '個',
|
||||
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
];
|
||||
|
||||
@@ -26,9 +26,12 @@ class WarehouseController extends Controller
|
||||
|
||||
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
|
||||
->withSum(['inventories as available_stock' => function ($query) {
|
||||
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期)
|
||||
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期) 且 倉庫類型不為瑕疵倉
|
||||
$query->where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
->orWhere('expiry_date', '>=', now());
|
||||
@@ -38,20 +41,15 @@ class WarehouseController extends Controller
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
// 修正各倉庫列表中的可用庫存計算:若倉庫不可銷售,則可用庫存為 0
|
||||
$warehouses->getCollection()->transform(function ($w) {
|
||||
if (!$w->is_sellable) {
|
||||
$w->available_stock = 0;
|
||||
}
|
||||
return $w;
|
||||
});
|
||||
// 移除原本對 is_sellable 的手動修正邏輯,現在由 type 自動過濾
|
||||
|
||||
|
||||
// 計算全域總計 (不分頁)
|
||||
$totals = [
|
||||
'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
|
||||
->where('quality_status', 'normal')
|
||||
->whereHas('warehouse', function ($q) {
|
||||
$q->where('is_sellable', true);
|
||||
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expiry_date')
|
||||
@@ -73,7 +71,6 @@ class WarehouseController extends Controller
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
@@ -98,7 +95,6 @@ class WarehouseController extends Controller
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
|
||||
51
app/Modules/Inventory/Models/GoodsReceipt.php
Normal file
51
app/Modules/Inventory/Models/GoodsReceipt.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class GoodsReceipt extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'type',
|
||||
'warehouse_id',
|
||||
'purchase_order_id',
|
||||
'vendor_id',
|
||||
'received_date',
|
||||
'status',
|
||||
'remarks',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'received_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(GoodsReceiptItem::class);
|
||||
}
|
||||
|
||||
// Strict Mode: relationships to Warehouse is allowed (same module).
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
// Strict Mode: cross-module relationship to Vendor/User/PurchaseOrder is restricted.
|
||||
// They are accessed via IDs or Services.
|
||||
}
|
||||
39
app/Modules/Inventory/Models/GoodsReceiptItem.php
Normal file
39
app/Modules/Inventory/Models/GoodsReceiptItem.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class GoodsReceiptItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'goods_receipt_id',
|
||||
'product_id',
|
||||
'purchase_order_item_id',
|
||||
'quantity_received',
|
||||
'unit_price',
|
||||
'total_amount',
|
||||
'batch_number',
|
||||
'expiry_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_received' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2', // 暫定價格
|
||||
'total_amount' => 'decimal:2',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function goodsReceipt()
|
||||
{
|
||||
return $this->belongsTo(GoodsReceipt::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
81
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
81
app/Modules/Inventory/Models/InventoryAdjustDoc.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryAdjustDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'count_doc_id',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'reason',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'ADJ' . $today;
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function countDoc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryAdjustItem::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
}
|
||||
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
36
app/Modules/Inventory/Models/InventoryAdjustItem.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryAdjustItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'adjust_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'qty_before',
|
||||
'adjust_qty', // 增減數量
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty_before' => 'decimal:2',
|
||||
'adjust_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryAdjustDoc::class, 'adjust_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
78
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
78
app/Modules/Inventory/Models/InventoryCountDoc.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryCountDoc extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'snapshot_date',
|
||||
'completed_at',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'completed_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_date' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'CNT' . $today;
|
||||
|
||||
// 查詢當天編號最大的單據
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
// 取得最後兩位序號並遞增
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryCountItem::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function completedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'completed_by');
|
||||
}
|
||||
}
|
||||
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
38
app/Modules/Inventory/Models/InventoryCountItem.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryCountItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'count_doc_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'system_qty',
|
||||
'counted_qty',
|
||||
'diff_qty',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'system_qty' => 'decimal:2',
|
||||
'counted_qty' => 'decimal:2',
|
||||
'diff_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function doc(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryCountDoc::class, 'count_doc_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
34
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
34
app/Modules/Inventory/Models/InventoryTransferItem.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryTransferItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'transfer_order_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
66
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
66
app/Modules/Inventory/Models/InventoryTransferOrder.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class InventoryTransferOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'from_warehouse_id',
|
||||
'to_warehouse_id',
|
||||
'status',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$model->doc_no = 'TRF-' . date('YmdHis') . '-' . rand(100, 999);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function fromWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'from_warehouse_id');
|
||||
}
|
||||
|
||||
public function toWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'to_warehouse_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryTransferItem::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,11 @@ class Warehouse extends Model
|
||||
'type',
|
||||
'address',
|
||||
'description',
|
||||
'is_sellable',
|
||||
'license_plate',
|
||||
'driver_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_sellable' => 'boolean',
|
||||
'type' => \App\Enums\WarehouseType::class,
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Modules\Inventory\Controllers\WarehouseController;
|
||||
use App\Modules\Inventory\Controllers\InventoryController;
|
||||
use App\Modules\Inventory\Controllers\SafetyStockController;
|
||||
use App\Modules\Inventory\Controllers\TransferOrderController;
|
||||
use App\Modules\Inventory\Controllers\CountDocController;
|
||||
use App\Modules\Inventory\Controllers\AdjustDocController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
@@ -70,11 +72,48 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 撥補單 (在庫存調撥時使用)
|
||||
// 庫存盤點 (Stock Counting) - Global
|
||||
Route::middleware('permission:inventory.view')->group(function () {
|
||||
Route::get('/inventory/count-docs', [CountDocController::class, 'index'])->name('inventory.count.index');
|
||||
Route::get('/inventory/count-docs/{doc}', [CountDocController::class, 'show'])->name('inventory.count.show');
|
||||
|
||||
Route::middleware('permission:inventory.adjust')->group(function () {
|
||||
Route::post('/inventory/count-docs', [CountDocController::class, 'store'])->name('inventory.count.store');
|
||||
Route::put('/inventory/count-docs/{doc}', [CountDocController::class, 'update'])->name('inventory.count.update');
|
||||
Route::delete('/inventory/count-docs/{doc}', [CountDocController::class, 'destroy'])->name('inventory.count.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
// 庫存盤調 (Stock Adjustment) - Global
|
||||
Route::middleware('permission:inventory.adjust')->group(function () {
|
||||
Route::get('/inventory/adjust-docs', [AdjustDocController::class, 'index'])->name('inventory.adjust.index');
|
||||
Route::get('/inventory/adjust-docs/get-pending-counts', [AdjustDocController::class, 'getPendingCounts'])->name('inventory.adjust.pending-counts');
|
||||
Route::get('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'show'])->name('inventory.adjust.show');
|
||||
Route::post('/inventory/adjust-docs', [AdjustDocController::class, 'store'])->name('inventory.adjust.store');
|
||||
Route::put('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'update'])->name('inventory.adjust.update');
|
||||
Route::delete('/inventory/adjust-docs/{doc}', [AdjustDocController::class, 'destroy'])->name('inventory.adjust.destroy');
|
||||
});
|
||||
|
||||
// 撥補單/調撥單 (Transfer Order) - Global
|
||||
Route::middleware('permission:inventory.transfer')->group(function () {
|
||||
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
||||
Route::get('/inventory/transfer-orders', [TransferOrderController::class, 'index'])->name('inventory.transfer.index');
|
||||
Route::get('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'show'])->name('inventory.transfer.show');
|
||||
Route::post('/inventory/transfer-orders', [TransferOrderController::class, 'store'])->name('inventory.transfer.store');
|
||||
Route::put('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'update'])->name('inventory.transfer.update');
|
||||
Route::delete('/inventory/transfer-orders/{order}', [TransferOrderController::class, 'destroy'])->name('inventory.transfer.destroy');
|
||||
});
|
||||
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
|
||||
->middleware('permission:inventory.view')
|
||||
->name('api.warehouses.inventories');
|
||||
|
||||
// 進貨單 (Goods Receipts)
|
||||
Route::middleware('permission:goods_receipts.view')->group(function () {
|
||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
||||
Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create');
|
||||
Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show');
|
||||
Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
|
||||
Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos');
|
||||
Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products');
|
||||
Route::get('/api/goods-receipts/search-vendors', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchVendors'])->name('goods-receipts.search-vendors');
|
||||
});
|
||||
});
|
||||
|
||||
153
app/Modules/Inventory/Services/AdjustService.php
Normal file
153
app/Modules/Inventory/Services/AdjustService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||
use App\Modules\Inventory\Models\InventoryAdjustItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdjustService
|
||||
{
|
||||
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
|
||||
{
|
||||
return InventoryAdjustDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'count_doc_id' => $countDocId,
|
||||
'status' => 'draft',
|
||||
'reason' => $reason,
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從盤點單建立盤調單
|
||||
*/
|
||||
public function createFromCountDoc(InventoryCountDoc $countDoc, int $userId): InventoryAdjustDoc
|
||||
{
|
||||
return DB::transaction(function () use ($countDoc, $userId) {
|
||||
// 1. 建立盤調單頭
|
||||
$adjDoc = $this->createDoc(
|
||||
$countDoc->warehouse_id,
|
||||
"盤點調整: " . $countDoc->doc_no,
|
||||
"由盤點單 {$countDoc->doc_no} 自動生成",
|
||||
$userId,
|
||||
$countDoc->id
|
||||
);
|
||||
|
||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||
foreach ($countDoc->items as $item) {
|
||||
if (abs($item->diff_qty) < 0.0001) continue;
|
||||
|
||||
$adjDoc->items()->create([
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'qty_before' => $item->system_qty,
|
||||
'adjust_qty' => $item->diff_qty,
|
||||
'notes' => "盤點差異: " . $item->diff_qty,
|
||||
]);
|
||||
}
|
||||
|
||||
return $adjDoc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調整單內容 (Items)
|
||||
* 此處採用 "全量更新" 方式處理 items (先刪後加),簡單可靠
|
||||
*/
|
||||
public function updateItems(InventoryAdjustDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
$doc->items()->delete();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->where('product_id', $data['product_id'])
|
||||
->where('batch_number', $data['batch_number'] ?? null)
|
||||
->first();
|
||||
|
||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||
|
||||
$doc->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'qty_before' => $qtyBefore,
|
||||
'adjust_qty' => $data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 生效庫存異動
|
||||
*/
|
||||
public function post(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
foreach ($doc->items as $item) {
|
||||
if ($item->adjust_qty == 0) continue;
|
||||
|
||||
// 找尋或建立 Inventory
|
||||
// 若是減少庫存,必須確保 Inventory 存在 (且理論上不能變負? 視策略而定,這裡假設允許變負或由 InventoryService 控管)
|
||||
// 若是增加庫存,若不存在需建立
|
||||
|
||||
$inventory = Inventory::firstOrNew([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
]);
|
||||
|
||||
// 如果是新建立的 object (id 為空),需要初始化 default
|
||||
if (!$inventory->exists) {
|
||||
// 繼承 Product 成本或預設 0 (簡化處理)
|
||||
$inventory->unit_cost = $item->product->cost ?? 0;
|
||||
$inventory->quantity = 0;
|
||||
}
|
||||
|
||||
$oldQty = $inventory->quantity;
|
||||
$newQty = $oldQty + $item->adjust_qty;
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
// 用最新的數量 * 單位成本 (簡化成本計算,不採用移動加權)
|
||||
$inventory->total_value = $newQty * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 建立 Transaction
|
||||
$inventory->transactions()->create([
|
||||
'type' => '庫存調整',
|
||||
'quantity' => $item->adjust_qty,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $oldQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => "調整單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$doc->update([
|
||||
'status' => 'posted',
|
||||
'posted_at' => now(),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作廢 (Void)
|
||||
*/
|
||||
public function void(InventoryAdjustDoc $doc, int $userId): void
|
||||
{
|
||||
if ($doc->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$doc->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
109
app/Modules/Inventory/Services/CountService.php
Normal file
109
app/Modules/Inventory/Services/CountService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||
use App\Modules\Inventory\Models\InventoryCountItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CountService
|
||||
{
|
||||
/**
|
||||
* 建立新的盤點單並執行快照
|
||||
*/
|
||||
public function createDoc(string $warehouseId, string $remarks = null, int $userId): InventoryCountDoc
|
||||
{
|
||||
return DB::transaction(function () use ($warehouseId, $remarks, $userId) {
|
||||
$doc = InventoryCountDoc::create([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return $doc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行快照:鎖定當前庫存量
|
||||
*/
|
||||
public function snapshot(InventoryCountDoc $doc): void
|
||||
{
|
||||
DB::transaction(function () use ($doc) {
|
||||
// 清除舊的 items (如果有)
|
||||
$doc->items()->delete();
|
||||
|
||||
// 取得該倉庫所有庫存 (包含 quantity = 0 但未軟刪除的)
|
||||
// 這裡可以根據需求決定是否要過濾掉 0 庫存,通常盤點單會希望能看到所有 "帳上有紀錄" 的東西
|
||||
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
foreach ($inventories as $inv) {
|
||||
$items[] = [
|
||||
'count_doc_id' => $doc->id,
|
||||
'product_id' => $inv->product_id,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'system_qty' => $inv->quantity,
|
||||
'counted_qty' => null, // 預設未盤點
|
||||
'diff_qty' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($items)) {
|
||||
InventoryCountItem::insert($items);
|
||||
}
|
||||
|
||||
$doc->update([
|
||||
'status' => 'counting',
|
||||
'snapshot_date' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成盤點:過帳差異
|
||||
*/
|
||||
public function complete(InventoryCountDoc $doc, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $userId) {
|
||||
// 僅更新單據狀態為「已完成」,不執行庫存入庫/調整
|
||||
// 盤點單僅作為記錄,後續調整由盤調單 (AdjustDoc) 執行
|
||||
$doc->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'completed_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新盤點數量
|
||||
*/
|
||||
public function updateCount(InventoryCountDoc $doc, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($doc, $itemsData) {
|
||||
foreach ($itemsData as $data) {
|
||||
$item = $doc->items()->find($data['id']);
|
||||
if ($item) {
|
||||
$countedQty = $data['counted_qty'];
|
||||
$diff = is_numeric($countedQty) ? ($countedQty - $item->system_qty) : 0;
|
||||
|
||||
$item->update([
|
||||
'counted_qty' => $countedQty,
|
||||
'diff_qty' => $diff,
|
||||
'notes' => $data['notes'] ?? $item->notes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
109
app/Modules/Inventory/Services/GoodsReceiptService.php
Normal file
109
app/Modules/Inventory/Services/GoodsReceiptService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GoodsReceiptService
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Goods Receipt and process inventory.
|
||||
*
|
||||
* @param array $data
|
||||
* @return GoodsReceipt
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function store(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// 1. Generate Code
|
||||
$data['code'] = $this->generateCode($data['received_date']);
|
||||
$data['user_id'] = auth()->id();
|
||||
$data['status'] = 'completed'; // Direct completion for now
|
||||
|
||||
// 2. Create Header
|
||||
$goodsReceipt = GoodsReceipt::create($data);
|
||||
|
||||
// 3. Process Items
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// Create GR Item
|
||||
$grItem = new GoodsReceiptItem([
|
||||
'product_id' => $itemData['product_id'],
|
||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||
'quantity_received' => $itemData['quantity_received'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
$goodsReceipt->items()->save($grItem);
|
||||
|
||||
// 4. Update Inventory
|
||||
$reason = match($goodsReceipt->type) {
|
||||
'standard' => '採購進貨',
|
||||
'miscellaneous' => '雜項入庫',
|
||||
'other' => '其他入庫',
|
||||
default => '進貨入庫',
|
||||
};
|
||||
|
||||
$this->inventoryService->createInventoryRecord([
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'product_id' => $grItem->product_id,
|
||||
'quantity' => $grItem->quantity_received,
|
||||
'unit_cost' => $grItem->unit_price,
|
||||
'batch_number' => $grItem->batch_number,
|
||||
'expiry_date' => $grItem->expiry_date,
|
||||
'reason' => $reason,
|
||||
'reference_type' => GoodsReceipt::class,
|
||||
'reference_id' => $goodsReceipt->id,
|
||||
'source_purchase_order_id' => $goodsReceipt->purchase_order_id,
|
||||
'arrival_date' => $goodsReceipt->received_date,
|
||||
]);
|
||||
|
||||
// 5. Update PO if linked and type is standard
|
||||
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) {
|
||||
$this->procurementService->updateReceivedQuantity(
|
||||
$grItem->purchase_order_item_id,
|
||||
$grItem->quantity_received
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $goodsReceipt;
|
||||
});
|
||||
}
|
||||
|
||||
private function generateCode(string $date)
|
||||
{
|
||||
// Format: GR + YYYYMMDD + NNN
|
||||
$prefix = 'GR' . date('Ymd', strtotime($date));
|
||||
|
||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||
->orderBy('id', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($last) {
|
||||
$seq = intval(substr($last->code, -3)) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getAllProducts()
|
||||
{
|
||||
return Product::with(['baseUnit'])->get();
|
||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getUnits()
|
||||
@@ -32,17 +32,17 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getProduct(int $id)
|
||||
{
|
||||
return Product::find($id);
|
||||
return Product::with(['baseUnit', 'largeUnit'])->find($id);
|
||||
}
|
||||
|
||||
public function getProductsByIds(array $ids)
|
||||
{
|
||||
return Product::whereIn('id', $ids)->get();
|
||||
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getProductsByName(string $name)
|
||||
{
|
||||
return Product::where('name', 'like', "%{$name}%")->get();
|
||||
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getWarehouse(int $id)
|
||||
|
||||
152
app/Modules/Inventory/Services/TransferService.php
Normal file
152
app/Modules/Inventory/Services/TransferService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
/**
|
||||
* 建立調撥單草稿
|
||||
*/
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder
|
||||
{
|
||||
return InventoryTransferOrder::create([
|
||||
'from_warehouse_id' => $fromWarehouseId,
|
||||
'to_warehouse_id' => $toWarehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調撥單明細
|
||||
*/
|
||||
public function updateItems(InventoryTransferOrder $order, array $itemsData): void
|
||||
{
|
||||
DB::transaction(function () use ($order, $itemsData) {
|
||||
$order->items()->delete();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
$order->items()->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的)
|
||||
*/
|
||||
public function post(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$fromWarehouse = $order->fromWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 處理來源倉 (扣除)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足"],
|
||||
]);
|
||||
}
|
||||
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
// 更新總值 (假設成本不變)
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源交易
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '調撥出庫',
|
||||
'quantity' => -$item->quantity,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
// 2. 處理目的倉 (增加)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $order->to_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'total_value' => 0,
|
||||
// 繼承其他屬性
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
);
|
||||
|
||||
// 若是新建立的,且成本為0,確保繼承成本
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
||||
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
||||
$targetInventory->save();
|
||||
|
||||
// 記錄目的交易
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '調撥入庫',
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'posted_at' => now(),
|
||||
'posted_by' => $userId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function void(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
throw new \Exception('只能作廢草稿狀態的單據');
|
||||
}
|
||||
$order->update([
|
||||
'status' => 'voided',
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,52 @@ interface ProcurementServiceInterface
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardStats(): array;
|
||||
|
||||
/**
|
||||
* Update received quantity for a PO item.
|
||||
*
|
||||
* @param int $poItemId
|
||||
* @param float $quantity
|
||||
* @return void
|
||||
*/
|
||||
public function updateReceivedQuantity(int $poItemId, float $quantity): void;
|
||||
|
||||
/**
|
||||
* Search pending or partial purchase orders.
|
||||
*
|
||||
* @param string $query
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchPendingPurchaseOrders(string $query): Collection;
|
||||
|
||||
/**
|
||||
* Search vendors by name or code.
|
||||
*
|
||||
* @param string $query
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchVendors(string $query): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有待進貨的採購單列表(不需搜尋條件)。
|
||||
* 用於進貨單頁面直接顯示可選擇的採購單。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPendingPurchaseOrders(): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有廠商列表。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllVendors(): Collection;
|
||||
|
||||
/**
|
||||
* Get vendors by multiple IDs.
|
||||
*
|
||||
* @param array $ids
|
||||
* @return Collection
|
||||
*/
|
||||
public function getVendorsByIds(array $ids): Collection;
|
||||
}
|
||||
|
||||
@@ -187,9 +187,10 @@ class PurchaseOrderController extends Controller
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// 生成單號:YYYYMMDD001
|
||||
// 生成單號:POYYYYMMDD001
|
||||
$today = now()->format('Ymd');
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
|
||||
$prefix = 'PO' . $today;
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
||||
->lockForUpdate() // 鎖定以避免並發衝突
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
@@ -201,7 +202,7 @@ class PurchaseOrderController extends Controller
|
||||
} else {
|
||||
$sequence = '001';
|
||||
}
|
||||
$code = $today . $sequence;
|
||||
$code = $prefix . $sequence;
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
@@ -419,7 +420,7 @@ class PurchaseOrderController extends Controller
|
||||
'order_date' => 'required|date', // 新增驗證
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
||||
'status' => 'required|string|in:draft,pending,approved,partial,completed,closed,cancelled',
|
||||
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||
'invoice_date' => 'nullable|date',
|
||||
'invoice_amount' => 'nullable|numeric|min:0',
|
||||
@@ -476,14 +477,21 @@ class PurchaseOrderController extends Controller
|
||||
$order->saveQuietly();
|
||||
|
||||
// 2. 捕捉包含商品名稱的舊項目以進行比對
|
||||
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
$oldItemsCollection = $order->items()->get();
|
||||
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
|
||||
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
|
||||
// 注意:單位的獲取可能也需要透過 InventoryService,但目前假設單位的關聯是合法的(如果在同一模組)
|
||||
// 如果單位也在不同模組,則需要另外處理。這裡暫時假設可以動手水和一下基本單位名稱。
|
||||
|
||||
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
|
||||
$product = $oldProducts->get($item->product_id);
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product?->name,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'unit_name' => 'N/A', // 簡化處理,或可透過服務獲取
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
@@ -513,14 +521,19 @@ class PurchaseOrderController extends Controller
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// 重新獲取新項目以確保擁有最新的關聯
|
||||
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
// 重新獲取新項目並水和產品資料
|
||||
$newItemsCollection = $order->items()->get();
|
||||
$newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray();
|
||||
$newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id');
|
||||
|
||||
$newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) {
|
||||
$product = $newProducts->get($item->product_id);
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product?->name,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'unit_name' => 'N/A',
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
@@ -95,14 +95,15 @@ class VendorController extends Controller
|
||||
if (!$product) return null;
|
||||
|
||||
return (object) [
|
||||
'id' => (string) $pivot->id,
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'unit' => $product->baseUnit?->name ?? 'N/A',
|
||||
'baseUnit' => $product->baseUnit?->name,
|
||||
'largeUnit' => $product->largeUnit?->name,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) $pivot->last_price,
|
||||
'id' => (string) $product->id, // Frontend expects product ID here as p.id
|
||||
'name' => $product->name,
|
||||
'baseUnit' => $product->baseUnit ? (object)['name' => $product->baseUnit->name] : null,
|
||||
'largeUnit' => $product->largeUnit ? (object)['name' => $product->largeUnit->name] : null,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'purchase_unit' => $product->purchaseUnit?->name,
|
||||
'pivot' => (object) [
|
||||
'last_price' => (float) $pivot->last_price,
|
||||
],
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
@@ -119,7 +120,7 @@ class VendorController extends Controller
|
||||
'email' => $vendor->email,
|
||||
'address' => $vendor->address,
|
||||
'remark' => $vendor->remark,
|
||||
'supplyProducts' => $supplyProducts,
|
||||
'products' => $supplyProducts, // Changed from supplyProducts to products
|
||||
];
|
||||
|
||||
return Inertia::render('Vendor/Show', [
|
||||
|
||||
@@ -29,4 +29,74 @@ class ProcurementService implements ProcurementServiceInterface
|
||||
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function updateReceivedQuantity(int $poItemId, float $quantity): void
|
||||
{
|
||||
$item = \App\Modules\Procurement\Models\PurchaseOrderItem::findOrFail($poItemId);
|
||||
$item->increment('received_quantity', $quantity);
|
||||
$item->refresh();
|
||||
|
||||
// Check PO status
|
||||
$po = $item->purchaseOrder;
|
||||
|
||||
// Load items to check completion
|
||||
$po->load('items');
|
||||
|
||||
$allReceived = $po->items->every(function ($i) {
|
||||
return $i->received_quantity >= $i->quantity;
|
||||
});
|
||||
|
||||
$anyReceived = $po->items->contains(function ($i) {
|
||||
return $i->received_quantity > 0;
|
||||
});
|
||||
|
||||
if ($allReceived) {
|
||||
$po->status = 'completed'; // or 'received' based on workflow
|
||||
} elseif ($anyReceived) {
|
||||
$po->status = 'partial';
|
||||
}
|
||||
|
||||
$po->save();
|
||||
}
|
||||
|
||||
public function searchPendingPurchaseOrders(string $query): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where(function($q) use ($query) {
|
||||
$q->where('code', 'like', "%{$query}%")
|
||||
->orWhereHas('vendor', function($vq) use ($query) {
|
||||
$vq->where('name', 'like', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function searchVendors(string $query): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::where('name', 'like', "%{$query}%")
|
||||
->orWhere('code', 'like', "%{$query}%")
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getPendingPurchaseOrders(): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(50)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getAllVendors(): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::orderBy('name')->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getVendorsByIds(array $ids): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Spatie\Permission\Exceptions\UnauthorizedException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// 信任所有代理(用於反向代理環境)
|
||||
TrustProxies::at('*');
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -18,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// 信任所有代理(用於反向代理環境)
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
// Tenancy 必須最先執行,確保資料庫連線在 Session 讀取之前建立
|
||||
$middleware->web(prepend: [
|
||||
\App\Http\Middleware\UniversalTenancy::class,
|
||||
|
||||
@@ -129,7 +129,7 @@ return [
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'_v2_session'
|
||||
),
|
||||
|
||||
/*
|
||||
|
||||
@@ -204,6 +204,6 @@ return [
|
||||
*/
|
||||
'seeder_parameters' => [
|
||||
'--class' => 'TenantDatabaseSeeder', // 租戶專用 seeder
|
||||
// '--force' => true, // This needs to be true to seed tenant databases in production
|
||||
'--force' => true, // 強制在正式環境執行 Seeder
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->create(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('log_name')->nullable();
|
||||
$table->text('description');
|
||||
$table->nullableMorphs('subject', 'subject');
|
||||
$table->nullableMorphs('causer', 'causer');
|
||||
$table->json('properties')->nullable();
|
||||
$table->timestamps();
|
||||
$table->index('log_name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddEventColumnToActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->string('event')->nullable()->after('subject_type');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->dropColumn('event');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddBatchUuidColumnToActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->uuid('batch_uuid')->nullable()->after('properties');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->dropColumn('batch_uuid');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
// 單欄索引:事件類型(高頻過濾條件)
|
||||
$table->index('event', 'idx_event');
|
||||
|
||||
// 單欄索引:批次 UUID(未來批次操作查詢)
|
||||
$table->index('batch_uuid', 'idx_batch_uuid');
|
||||
|
||||
// 複合索引 1:時間 + 事件類型(最常見的組合查詢)
|
||||
$table->index(['created_at', 'event'], 'idx_created_event');
|
||||
|
||||
// 複合索引 2:主體類型 + 主體 ID + 時間(查詢特定資源的操作歷史)
|
||||
$table->index(['subject_type', 'subject_id', 'created_at'], 'idx_subject_created');
|
||||
|
||||
// 複合索引 3:操作者 + 時間(查詢特定使用者的操作紀錄)
|
||||
$table->index(['causer_id', 'created_at'], 'idx_causer_created');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||
$table->dropIndex('idx_event');
|
||||
$table->dropIndex('idx_batch_uuid');
|
||||
$table->dropIndex('idx_created_event');
|
||||
$table->dropIndex('idx_subject_created');
|
||||
$table->dropIndex('idx_causer_created');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->dropColumn('is_sellable');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->boolean('is_sellable')->default(true)->after('description')->comment('是否可銷售');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('goods_receipts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->index(); // GR 單號
|
||||
$table->foreignId('warehouse_id')->constrained()->onDelete('restrict');
|
||||
$table->foreignId('purchase_order_id')->nullable()->constrained()->onDelete('set null');
|
||||
$table->foreignId('vendor_id')->constrained()->onDelete('restrict'); // 關聯到 Inventory 模組內的 Vendor 邏輯或跨模組 ID (此處僅 FK 約束通常指向同一 DB 的 vendors 表)
|
||||
$table->date('received_date');
|
||||
$table->enum('status', ['draft', 'completed', 'cancelled'])->default('draft');
|
||||
$table->text('remarks')->nullable();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('restrict'); // 經辦人
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('goods_receipt_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('goods_receipt_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('product_id')->constrained()->onDelete('restrict');
|
||||
$table->foreignId('purchase_order_item_id')->nullable()->constrained()->onDelete('set null'); // 用於回寫 PO Item
|
||||
$table->decimal('quantity_received', 10, 2);
|
||||
$table->decimal('unit_price', 10, 2); // 暫定價格 (來自 PO)
|
||||
$table->decimal('total_amount', 12, 2); // 小計
|
||||
$table->string('batch_number')->nullable();
|
||||
$table->date('expiry_date')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('goods_receipt_items');
|
||||
Schema::dropIfExists('goods_receipts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('goods_receipts', function (Blueprint $table) {
|
||||
$table->enum('type', ['standard', 'miscellaneous', 'other'])->default('standard')->after('warehouse_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('goods_receipts', function (Blueprint $table) {
|
||||
$table->dropColumn('type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Update old statuses to 'approved'
|
||||
DB::table('purchase_orders')
|
||||
->whereIn('status', ['processing', 'shipping', 'confirming'])
|
||||
->update(['status' => 'approved']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Cannot easily reverse without knowing original status,
|
||||
// but typically we can revert 'approved' back to 'processing' as a safeguard if needed,
|
||||
// or just leave it since 'approved' is broader.
|
||||
// For strict reversal, we might try to map back, but effectively this is a one-way consolidation.
|
||||
// We will leave it as is for down/safe side.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inventory_count_docs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('doc_no')->unique(); // 單號
|
||||
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->string('status')->default('draft'); // draft, counting, completed, cancelled
|
||||
$table->timestamp('snapshot_date')->nullable(); // 快照建立時間
|
||||
$table->timestamp('completed_at')->nullable(); // 完成時間
|
||||
$table->string('remarks')->nullable();
|
||||
|
||||
// 審核/建立資訊
|
||||
$table->foreignId('created_by')->constrained('users');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users');
|
||||
$table->foreignId('completed_by')->nullable()->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('inventory_count_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('count_doc_id')->constrained('inventory_count_docs')->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained('products');
|
||||
$table->string('batch_number')->nullable(); // 針對特定批號盤點
|
||||
|
||||
$table->decimal('system_qty', 10, 2)->default(0); // 系統帳面數量 (快照當下)
|
||||
$table->decimal('counted_qty', 10, 2)->nullable(); // 實盤數量
|
||||
$table->decimal('diff_qty', 10, 2)->default(0); // 差異 (實盤 - 系統)
|
||||
$table->string('notes')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_count_items');
|
||||
Schema::dropIfExists('inventory_count_docs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inventory_adjust_docs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('doc_no')->unique(); // 單號
|
||||
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->string('status')->default('draft'); // draft, posted, voided
|
||||
$table->string('reason')->nullable(); // 調整原因 (e.g. 報廢, 盤盈虧, 其他)
|
||||
$table->string('remarks')->nullable();
|
||||
|
||||
// 審核/建立資訊
|
||||
$table->timestamp('posted_at')->nullable(); // 過帳時間
|
||||
$table->foreignId('created_by')->constrained('users');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users');
|
||||
$table->foreignId('posted_by')->nullable()->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('inventory_adjust_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('adjust_doc_id')->constrained('inventory_adjust_docs')->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained('products');
|
||||
$table->string('batch_number')->nullable();
|
||||
|
||||
// 記錄當下 "調整前" 的庫存與成本 (參考用)
|
||||
$table->decimal('qty_before', 10, 2)->default(0);
|
||||
|
||||
// 實際調整的數量 (可以正負, 正=增加, 負=減少)
|
||||
$table->decimal('adjust_qty', 10, 2);
|
||||
|
||||
$table->string('notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_adjust_items');
|
||||
Schema::dropIfExists('inventory_adjust_docs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inventory_transfer_orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('doc_no')->unique();
|
||||
$table->foreignId('from_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->foreignId('to_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
|
||||
$table->string('status')->default('draft'); // draft, completed, voided
|
||||
$table->string('remarks')->nullable();
|
||||
|
||||
// 審核/建立資訊
|
||||
$table->timestamp('posted_at')->nullable(); // 過帳時間
|
||||
$table->foreignId('created_by')->constrained('users');
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users');
|
||||
$table->foreignId('posted_by')->nullable()->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('inventory_transfer_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('transfer_order_id')->constrained('inventory_transfer_orders')->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained('products');
|
||||
$table->string('batch_number')->nullable();
|
||||
$table->decimal('quantity', 10, 2);
|
||||
$table->string('notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_transfer_items');
|
||||
Schema::dropIfExists('inventory_transfer_orders');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventory_adjust_docs', function (Blueprint $table) {
|
||||
$table->foreignId('count_doc_id')
|
||||
->after('doc_no')
|
||||
->nullable()
|
||||
->constrained('inventory_count_docs')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_adjust_docs', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('count_doc_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -36,7 +36,27 @@ class PermissionSeeder extends Seeder
|
||||
'inventory.view',
|
||||
'inventory.view_cost', // 查看成本與價值
|
||||
'inventory.adjust',
|
||||
'inventory.transfer',
|
||||
'inventory.count', // 庫存盤點
|
||||
'inventory.transfer', // 庫存調撥
|
||||
'inventory.delete',
|
||||
|
||||
// 進貨單管理
|
||||
'goods_receipts.view',
|
||||
'goods_receipts.create',
|
||||
'goods_receipts.edit',
|
||||
'goods_receipts.delete',
|
||||
|
||||
// 生產工單管理
|
||||
'production_orders.view',
|
||||
'production_orders.create',
|
||||
'production_orders.edit',
|
||||
'production_orders.delete',
|
||||
|
||||
// 配方管理
|
||||
'recipes.view',
|
||||
'recipes.create',
|
||||
'recipes.edit',
|
||||
'recipes.delete',
|
||||
|
||||
// 供應商管理
|
||||
'vendors.view',
|
||||
@@ -97,7 +117,10 @@ class PermissionSeeder extends Seeder
|
||||
'products.view', 'products.create', 'products.edit', 'products.delete',
|
||||
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||
'purchase_orders.delete', 'purchase_orders.publish',
|
||||
'inventory.view', 'inventory.view_cost', 'inventory.adjust', 'inventory.transfer',
|
||||
'inventory.view', 'inventory.view_cost', 'inventory.adjust', 'inventory.transfer', 'inventory.delete',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete',
|
||||
'recipes.view', 'recipes.create', 'recipes.edit', 'recipes.delete',
|
||||
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
||||
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
||||
'users.view', 'users.create', 'users.edit',
|
||||
@@ -110,7 +133,9 @@ class PermissionSeeder extends Seeder
|
||||
// warehouse-manager 管理庫存與倉庫
|
||||
$warehouseManager->givePermissionTo([
|
||||
'products.view',
|
||||
'inventory.view', 'inventory.adjust', 'inventory.transfer',
|
||||
'inventory.view', 'inventory.adjust', 'inventory.count', 'inventory.transfer', 'inventory.delete',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
||||
'warehouses.view', 'warehouses.create', 'warehouses.edit',
|
||||
]);
|
||||
|
||||
@@ -120,6 +145,7 @@ class PermissionSeeder extends Seeder
|
||||
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||
'vendors.view', 'vendors.create', 'vendors.edit',
|
||||
'inventory.view',
|
||||
'goods_receipts.view', 'goods_receipts.create',
|
||||
]);
|
||||
|
||||
// viewer 僅能查看
|
||||
@@ -127,6 +153,7 @@ class PermissionSeeder extends Seeder
|
||||
'products.view',
|
||||
'purchase_orders.view',
|
||||
'inventory.view',
|
||||
'goods_receipts.view',
|
||||
'vendors.view',
|
||||
'warehouses.view',
|
||||
'utility_fees.view',
|
||||
|
||||
@@ -34,6 +34,9 @@ class TenantDatabaseSeeder extends Seeder
|
||||
// 呼叫權限 Seeder 設定權限與角色
|
||||
$this->call(PermissionSeeder::class);
|
||||
|
||||
// 初始化基本單位資料
|
||||
$this->call(UnitSeeder::class);
|
||||
|
||||
// 確保 admin 擁有 super-admin 角色
|
||||
if (!$admin->hasRole('super-admin')) {
|
||||
$admin->assignRole('super-admin');
|
||||
|
||||
@@ -15,6 +15,11 @@ fi
|
||||
|
||||
chmod -R ugo+rw /.composer
|
||||
|
||||
# 確保 storage 軟連結存在
|
||||
if [ ! -L /var/www/html/public/storage ]; then
|
||||
php /var/www/html/artisan storage:link
|
||||
fi
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
||||
exec "$@"
|
||||
|
||||
@@ -15,6 +15,11 @@ fi
|
||||
|
||||
chmod -R ugo+rw /.composer
|
||||
|
||||
# 確保 storage 軟連結存在
|
||||
if [ ! -L /var/www/html/public/storage ]; then
|
||||
php /var/www/html/artisan storage:link
|
||||
fi
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
||||
exec "$@"
|
||||
|
||||
@@ -114,10 +114,24 @@ const fieldLabels: Record<string, string> = {
|
||||
transaction_date: '費用日期',
|
||||
category: '費用類別',
|
||||
amount: '金額',
|
||||
// 進貨單欄位
|
||||
gr_number: '進貨單號',
|
||||
received_date: '入庫日期',
|
||||
type: '入庫類型',
|
||||
remarks: '備註',
|
||||
// 生產管理欄位
|
||||
production_number: '工單編號',
|
||||
production_date: '生產日期',
|
||||
actual_quantity: '實際產量',
|
||||
consumption_status: '物料消耗狀態',
|
||||
recipe_id: '生產配方',
|
||||
recipe_name: '配方名稱',
|
||||
yield_quantity: '預期產量',
|
||||
};
|
||||
|
||||
// 採購單狀態對照表
|
||||
// 狀態翻譯對照表
|
||||
const statusMap: Record<string, string> = {
|
||||
// 採購單狀態
|
||||
draft: '草稿',
|
||||
pending: '待審核',
|
||||
approved: '已核准',
|
||||
@@ -125,6 +139,10 @@ const statusMap: Record<string, string> = {
|
||||
received: '已收貨',
|
||||
cancelled: '已取消',
|
||||
completed: '已完成',
|
||||
// 生產工單狀態
|
||||
planned: '已計畫',
|
||||
in_progress: '生產中',
|
||||
// completed 已定義
|
||||
};
|
||||
|
||||
// 庫存品質狀態對照表
|
||||
|
||||
101
resources/js/Components/Inventory/GoodsReceiptActions.tsx
Normal file
101
resources/js/Components/Inventory/GoodsReceiptActions.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Link, useForm } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
|
||||
export interface GoodsReceipt {
|
||||
id: number;
|
||||
code: string;
|
||||
warehouse_id: number;
|
||||
warehouse?: { name: string };
|
||||
vendor_id?: number;
|
||||
vendor?: { name: string };
|
||||
received_date: string;
|
||||
status: string;
|
||||
type?: string;
|
||||
items_sum_total_amount?: number;
|
||||
user?: { name: string };
|
||||
}
|
||||
|
||||
export default function GoodsReceiptActions({
|
||||
receipt,
|
||||
}: { receipt: GoodsReceipt }) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const { delete: destroy, processing } = useForm({});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
// @ts-ignore
|
||||
destroy(route('goods-receipts.destroy', receipt.id), {
|
||||
onSuccess: () => {
|
||||
toast.success("進貨單已成功刪除");
|
||||
setShowDeleteDialog(false);
|
||||
},
|
||||
onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href={route('goods-receipts.show', receipt.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="查看詳情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Delete typically restricted for Goods Receipts, checking permission */}
|
||||
<Can permission="goods_receipts.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除進貨單</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除進貨單 「{receipt.code}」 嗎?
|
||||
<br />
|
||||
<span className="text-red-500 font-bold mt-2 block">
|
||||
注意:刪除進貨單將會扣除已入庫的庫存數量!
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="button-filled-error"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
|
||||
|
||||
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
|
||||
processing: { label: "處理中", variant: "warning" },
|
||||
completed: { label: "已完成", variant: "success" },
|
||||
cancelled: { label: "已取消", variant: "destructive" },
|
||||
};
|
||||
|
||||
interface GoodsReceiptStatusBadgeProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GoodsReceiptStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: GoodsReceiptStatusBadgeProps) {
|
||||
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
|
||||
// Apply custom styling based on variant mapping if not using standard badge variants
|
||||
let badgeClass = "";
|
||||
switch (config.variant) {
|
||||
case "success":
|
||||
badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
|
||||
break;
|
||||
case "warning":
|
||||
badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
|
||||
break;
|
||||
case "destructive":
|
||||
badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
|
||||
break;
|
||||
default:
|
||||
badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
258
resources/js/Components/Inventory/GoodsReceiptTable.tsx
Normal file
258
resources/js/Components/Inventory/GoodsReceiptTable.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 進貨單列表表格
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import GoodsReceiptActions, { GoodsReceipt } from "./GoodsReceiptActions";
|
||||
import GoodsReceiptStatusBadge from "./GoodsReceiptStatusBadge";
|
||||
import CopyButton from "@/Components/shared/CopyButton";
|
||||
import { formatCurrency, formatDate } from "@/utils/format";
|
||||
|
||||
interface GoodsReceiptTableProps {
|
||||
receipts: GoodsReceipt[];
|
||||
}
|
||||
|
||||
type SortField = "code" | "type" | "warehouse_name" | "vendor_name" | "received_date" | "total_amount" | "status";
|
||||
type SortDirection = "asc" | "desc" | null;
|
||||
|
||||
export default function GoodsReceiptTable({
|
||||
receipts,
|
||||
}: GoodsReceiptTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
||||
|
||||
// 處理排序
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortDirection === "asc") {
|
||||
setSortDirection("desc");
|
||||
} else if (sortDirection === "desc") {
|
||||
setSortDirection(null);
|
||||
setSortField(null);
|
||||
} else {
|
||||
setSortDirection("asc");
|
||||
}
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 類型翻譯映射
|
||||
const typeMap: Record<string, string> = {
|
||||
standard: "標準採購",
|
||||
miscellaneous: "雜項入庫",
|
||||
other: "其他入庫",
|
||||
};
|
||||
|
||||
// 排序後的進貨單列表
|
||||
const sortedReceipts = useMemo(() => {
|
||||
if (!sortField || !sortDirection) {
|
||||
return receipts;
|
||||
}
|
||||
|
||||
return [...receipts].sort((a, b) => {
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
|
||||
switch (sortField) {
|
||||
case "code":
|
||||
aValue = a.code;
|
||||
bValue = b.code;
|
||||
break;
|
||||
case "type":
|
||||
aValue = typeMap[a.status] || a.status; // status here might actually refer to type in existing code logic? Let's use a.type if it exists.
|
||||
// Checking if 'type' is in receipt - based on implementation plan we want it.
|
||||
// Currently GoodsReceipt model HAS type.
|
||||
// @ts-ignore
|
||||
aValue = typeMap[a.type] || a.type || "";
|
||||
// @ts-ignore
|
||||
bValue = typeMap[b.type] || b.type || "";
|
||||
break;
|
||||
case "warehouse_name":
|
||||
aValue = a.warehouse?.name || "";
|
||||
bValue = b.warehouse?.name || "";
|
||||
break;
|
||||
case "vendor_name":
|
||||
aValue = a.vendor?.name || "";
|
||||
bValue = b.vendor?.name || "";
|
||||
break;
|
||||
case "received_date":
|
||||
aValue = a.received_date;
|
||||
bValue = b.received_date;
|
||||
break;
|
||||
case "total_amount":
|
||||
aValue = a.items_sum_total_amount || 0;
|
||||
bValue = b.items_sum_total_amount || 0;
|
||||
break;
|
||||
case "status":
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return sortDirection === "asc"
|
||||
? aValue.localeCompare(bValue, "zh-TW")
|
||||
: bValue.localeCompare(aValue, "zh-TW");
|
||||
} else {
|
||||
return sortDirection === "asc"
|
||||
? (aValue as number) - (bValue as number)
|
||||
: (bValue as number) - (aValue as number);
|
||||
}
|
||||
});
|
||||
}, [receipts, sortField, sortDirection]);
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) {
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
if (sortDirection === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
if (sortDirection === "desc") {
|
||||
return <ArrowDown className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden mt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("code")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
進貨單編號
|
||||
<SortIcon field="code" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
<button
|
||||
onClick={() => handleSort("type")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
入庫類型
|
||||
<SortIcon field="type" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("warehouse_name")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
入庫倉庫
|
||||
<SortIcon field="warehouse_name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("vendor_name")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
供應商
|
||||
<SortIcon field="vendor_name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">
|
||||
<button
|
||||
onClick={() => handleSort("received_date")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
進貨日期
|
||||
<SortIcon field="received_date" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-right">
|
||||
<button
|
||||
onClick={() => handleSort("total_amount")}
|
||||
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors"
|
||||
>
|
||||
總金額
|
||||
<SortIcon field="total_amount" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px] text-center">
|
||||
<button
|
||||
onClick={() => handleSort("status")}
|
||||
className="flex items-center gap-2 mx-auto hover:text-foreground transition-colors"
|
||||
>
|
||||
狀態
|
||||
<SortIcon field="status" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedReceipts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-12">
|
||||
尚無進貨單
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedReceipts.map((receipt, index) => (
|
||||
<TableRow key={receipt.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-sm font-medium">{receipt.code}</span>
|
||||
<CopyButton text={receipt.code} label="複製單號" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{/* @ts-ignore */}
|
||||
{typeMap[receipt.type] || receipt.type || "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{receipt.warehouse?.name || "-"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-700">{receipt.vendor?.name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-500">{formatDate(receipt.received_date)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{formatCurrency(receipt.items_sum_total_amount)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<GoodsReceiptStatusBadge status={receipt.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<GoodsReceiptActions receipt={receipt} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import type { PurchaseOrderItem, Supplier } from "@/types/purchase-order";
|
||||
import { formatCurrency } from "@/utils/purchase-order";
|
||||
|
||||
@@ -204,14 +215,35 @@ export function PurchaseOrderItemsTable({
|
||||
{/* 刪除按鈕 */}
|
||||
{!isReadOnly && onRemoveItem && (
|
||||
<TableCell className="text-center">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="h-8 w-8 text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="移除項目"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要移除此商品嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此動作將從清單中移除該商品,您之後需要重新搜尋才能再次加入。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
確定移除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { PurchaseOrderStatus } from "@/types/purchase-order";
|
||||
import { STATUS_CONFIG } from "@/constants/purchase-order";
|
||||
|
||||
interface PurchaseOrderStatusBadgeProps {
|
||||
status: PurchaseOrderStatus;
|
||||
@@ -14,33 +15,12 @@ export default function PurchaseOrderStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: PurchaseOrderStatusBadgeProps) {
|
||||
const getStatusConfig = (status: PurchaseOrderStatus) => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return { label: "草稿", className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
case "pending":
|
||||
return { label: "待審核", className: "bg-blue-100 text-blue-700 border-blue-200" };
|
||||
case "processing":
|
||||
return { label: "處理中", className: "bg-yellow-100 text-yellow-700 border-yellow-200" };
|
||||
case "shipping":
|
||||
return { label: "運送中", className: "bg-purple-100 text-purple-700 border-purple-200" };
|
||||
case "confirming":
|
||||
return { label: "待確認", className: "bg-orange-100 text-orange-700 border-orange-200" };
|
||||
case "completed":
|
||||
return { label: "已完成", className: "bg-green-100 text-green-700 border-green-200" };
|
||||
case "cancelled":
|
||||
return { label: "已取消", className: "bg-red-100 text-red-700 border-red-200" };
|
||||
default:
|
||||
return { label: "未知", className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${config.className} ${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
variant={config.variant}
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
|
||||
@@ -10,13 +10,13 @@ interface StatusProgressBarProps {
|
||||
}
|
||||
|
||||
// 流程步驟定義
|
||||
const FLOW_STEPS: { key: PurchaseOrderStatus | "approved"; label: string }[] = [
|
||||
const FLOW_STEPS: { key: PurchaseOrderStatus; label: string }[] = [
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "pending", label: "待審核" },
|
||||
{ key: "processing", label: "處理中" },
|
||||
{ key: "shipping", label: "運送中" },
|
||||
{ key: "confirming", label: "待確認" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "pending", label: "簽核中" },
|
||||
{ key: "approved", label: "已核准" },
|
||||
{ key: "partial", label: "部分收貨" },
|
||||
{ key: "completed", label: "全數收貨" },
|
||||
{ key: "closed", label: "已結案" },
|
||||
];
|
||||
|
||||
export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
|
||||
@@ -82,7 +82,7 @@ export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isRejectedAtThisStep ? "已取消" : step.label}
|
||||
{isRejectedAtThisStep ? "已作廢" : step.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,12 +100,18 @@ export default function WarehouseCard({
|
||||
|
||||
{/* 統計區塊 - 狀態標籤 */}
|
||||
<div className="space-y-3">
|
||||
{/* 銷售狀態 */}
|
||||
{/* 銷售狀態與可用性說明 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">銷售狀態</span>
|
||||
<Badge variant={warehouse.is_sellable ? "default" : "secondary"} className={warehouse.is_sellable ? "bg-green-600" : "bg-gray-400"}>
|
||||
{warehouse.is_sellable ? "可銷售" : "暫停銷售"}
|
||||
<span className="text-sm text-gray-500">庫存可用性</span>
|
||||
{warehouse.type === 'quarantine' ? (
|
||||
<Badge variant="secondary" className="bg-red-100 text-red-700 border-red-200">
|
||||
不計入可用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
計入可用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 低庫存警告狀態 */}
|
||||
|
||||
@@ -62,7 +62,6 @@ export default function WarehouseDialog({
|
||||
address: string;
|
||||
description: string;
|
||||
type: WarehouseType;
|
||||
is_sellable: boolean;
|
||||
license_plate: string;
|
||||
driver_name: string;
|
||||
}>({
|
||||
@@ -71,7 +70,6 @@ export default function WarehouseDialog({
|
||||
address: "",
|
||||
description: "",
|
||||
type: "standard",
|
||||
is_sellable: true,
|
||||
license_plate: "",
|
||||
driver_name: "",
|
||||
});
|
||||
@@ -86,7 +84,6 @@ export default function WarehouseDialog({
|
||||
address: warehouse.address || "",
|
||||
description: warehouse.description || "",
|
||||
type: warehouse.type || "standard",
|
||||
is_sellable: warehouse.is_sellable ?? true,
|
||||
license_plate: warehouse.license_plate || "",
|
||||
driver_name: warehouse.driver_name || "",
|
||||
});
|
||||
@@ -97,7 +94,6 @@ export default function WarehouseDialog({
|
||||
address: "",
|
||||
description: "",
|
||||
type: "standard",
|
||||
is_sellable: true,
|
||||
license_plate: "",
|
||||
driver_name: "",
|
||||
});
|
||||
@@ -219,25 +215,7 @@ export default function WarehouseDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 銷售設定 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
<h4 className="text-sm text-gray-700">銷售設定</h4>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_sellable"
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-main focus:ring-primary-main"
|
||||
checked={formData.is_sellable}
|
||||
onChange={(e) => setFormData({ ...formData, is_sellable: e.target.checked })}
|
||||
/>
|
||||
<Label htmlFor="is_sellable">此倉庫可進行銷售扣庫</Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
啟用後,該倉庫庫存可用於 POS 或訂單銷售扣減。總倉通常不啟用,門市與行動販賣車需啟用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 區塊 B:位置 */}
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -21,10 +21,12 @@ import {
|
||||
Wallet,
|
||||
BarChart3,
|
||||
FileSpreadsheet,
|
||||
BookOpen
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
ArrowLeftRight
|
||||
} from "lucide-react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Link, usePage, Head } from "@inertiajs/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import BreadcrumbNav, { BreadcrumbItemType } from "@/Components/shared/BreadcrumbNav";
|
||||
@@ -82,7 +84,7 @@ export default function AuthenticatedLayout({
|
||||
id: "inventory-management",
|
||||
label: "商品與庫存管理",
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
permission: ["products.view", "warehouses.view"], // 滿足任一即可看到此群組
|
||||
permission: ["products.view", "warehouses.view", "inventory.view"], // 滿足任一即可看到此群組
|
||||
children: [
|
||||
{
|
||||
id: "product-management",
|
||||
@@ -98,13 +100,34 @@ export default function AuthenticatedLayout({
|
||||
route: "/warehouses",
|
||||
permission: "warehouses.view",
|
||||
},
|
||||
{
|
||||
id: "stock-counting",
|
||||
label: "庫存盤點",
|
||||
icon: <ClipboardCheck className="h-4 w-4" />,
|
||||
route: "/inventory/count-docs",
|
||||
permission: "inventory.view",
|
||||
},
|
||||
{
|
||||
id: "stock-adjustment",
|
||||
label: "庫存盤調",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
route: "/inventory/adjust-docs",
|
||||
permission: "inventory.adjust",
|
||||
},
|
||||
{
|
||||
id: "stock-transfer",
|
||||
label: "庫存調撥",
|
||||
icon: <ArrowLeftRight className="h-4 w-4" />,
|
||||
route: "/inventory/transfer-orders",
|
||||
permission: "inventory.transfer",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "vendor-management",
|
||||
label: "廠商管理",
|
||||
id: "supply-chain-management",
|
||||
label: "供應鏈管理",
|
||||
icon: <Truck className="h-5 w-5" />,
|
||||
permission: "vendors.view",
|
||||
permission: ["vendors.view", "purchase_orders.view", "goods_receipts.view"],
|
||||
children: [
|
||||
{
|
||||
id: "vendor-list",
|
||||
@@ -113,14 +136,6 @@ export default function AuthenticatedLayout({
|
||||
route: "/vendors",
|
||||
permission: "vendors.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "purchase-management",
|
||||
label: "採購管理",
|
||||
icon: <ShoppingCart className="h-5 w-5" />,
|
||||
permission: "purchase_orders.view",
|
||||
children: [
|
||||
{
|
||||
id: "purchase-order-list",
|
||||
label: "採購單管理",
|
||||
@@ -128,6 +143,20 @@ export default function AuthenticatedLayout({
|
||||
route: "/purchase-orders",
|
||||
permission: "purchase_orders.view",
|
||||
},
|
||||
{
|
||||
id: "goods-receipt-list",
|
||||
label: "進貨單管理",
|
||||
icon: <ClipboardCheck className="h-4 w-4" />,
|
||||
route: "/goods-receipts",
|
||||
permission: "goods_receipts.view",
|
||||
},
|
||||
// {
|
||||
// id: "delivery-note-list",
|
||||
// label: "出貨單管理 (開發中)",
|
||||
// icon: <Package className="h-4 w-4" />,
|
||||
// // route: "/delivery-notes",
|
||||
// permission: "delivery_notes.view",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -253,7 +282,11 @@ export default function AuthenticatedLayout({
|
||||
const activeItem = menuItems.find(item =>
|
||||
item.children?.some(child => child.route && url.startsWith(child.route))
|
||||
);
|
||||
return activeItem ? [activeItem.id] : ["inventory-management"];
|
||||
const defaultExpanded = ["inventory-management"];
|
||||
if (activeItem && !defaultExpanded.includes(activeItem.id)) {
|
||||
defaultExpanded.push(activeItem.id);
|
||||
}
|
||||
return defaultExpanded;
|
||||
});
|
||||
|
||||
// 監聽 URL 變化,確保「當前」頁面所屬群組是展開的
|
||||
@@ -277,17 +310,21 @@ export default function AuthenticatedLayout({
|
||||
}, [isCollapsed]);
|
||||
|
||||
// 全域監聽 flash 訊息並顯示 Toast
|
||||
const lastFlash = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
// @ts-ignore
|
||||
if (props.flash?.success) {
|
||||
// @ts-ignore
|
||||
if (!props.flash) return;
|
||||
|
||||
// 檢查是否與上次顯示的訊息相同(透過簡單的物件引用比對,Inertia 在重導向後會產生新的 props 物件)
|
||||
if (props.flash === lastFlash.current) return;
|
||||
|
||||
if (props.flash.success) {
|
||||
toast.success(props.flash.success);
|
||||
}
|
||||
// @ts-ignore
|
||||
if (props.flash?.error) {
|
||||
// @ts-ignore
|
||||
if (props.flash.error) {
|
||||
toast.error(props.flash.error);
|
||||
}
|
||||
|
||||
lastFlash.current = props.flash;
|
||||
}, [props.flash]);
|
||||
|
||||
const toggleExpand = (itemId: string) => {
|
||||
|
||||
@@ -71,10 +71,13 @@ export default function RoleCreate({ groupedPermissions }: Props) {
|
||||
'edit': '編輯',
|
||||
'delete': '刪除',
|
||||
'publish': '發布',
|
||||
'adjust': '新增 / 調整',
|
||||
'adjust': '調整',
|
||||
'transfer': '調撥',
|
||||
'safety_stock': '安全庫存設定',
|
||||
'export': '匯出',
|
||||
'complete': '完成',
|
||||
'view_cost': '檢視成本',
|
||||
'view_logs': '檢視日誌',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
|
||||
@@ -78,10 +78,13 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
|
||||
'edit': '編輯',
|
||||
'delete': '刪除',
|
||||
'publish': '發布',
|
||||
'adjust': '新增 / 調整',
|
||||
'adjust': '調整',
|
||||
'transfer': '調撥',
|
||||
'safety_stock': '安全庫存設定',
|
||||
'export': '匯出',
|
||||
'complete': '完成',
|
||||
'view_cost': '檢視成本',
|
||||
'view_logs': '檢視日誌',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
|
||||
409
resources/js/Pages/Inventory/Adjust/Index.tsx
Normal file
409
resources/js/Pages/Inventory/Adjust/Index.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, router } from '@inertiajs/react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Plus, Search, X, Eye, Pencil, ClipboardCheck } from "lucide-react";
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import Pagination from '@/Components/shared/Pagination';
|
||||
import { SearchableSelect } from '@/Components/ui/searchable-select';
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
import debounce from 'lodash/debounce';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Doc {
|
||||
id: string;
|
||||
doc_no: string;
|
||||
warehouse_name: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
posted_at: string;
|
||||
}
|
||||
|
||||
interface Warehouse {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search?: string;
|
||||
warehouse_id?: string;
|
||||
per_page?: string;
|
||||
}
|
||||
|
||||
interface DocsPagination {
|
||||
data: Doc[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: any[]; // Adjust type as needed for pagination links
|
||||
}
|
||||
|
||||
export default function Index({ docs, warehouses, filters }: { docs: DocsPagination, warehouses: Warehouse[], filters: Filters }) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState(filters.search || '');
|
||||
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || '');
|
||||
const [perPage, setPerPage] = useState(filters.per_page || '15');
|
||||
|
||||
// For Count Doc Selection
|
||||
const [pendingCounts, setPendingCounts] = useState<any[]>([]);
|
||||
const [loadingPending, setLoadingPending] = useState(false);
|
||||
const [scanSearch, setScanSearch] = useState('');
|
||||
|
||||
const fetchPendingCounts = useCallback(
|
||||
debounce((search = '') => {
|
||||
setLoadingPending(true);
|
||||
axios.get(route('inventory.adjust.pending-counts'), { params: { search } })
|
||||
.then(res => setPendingCounts(res.data))
|
||||
.finally(() => setLoadingPending(false));
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDialogOpen) {
|
||||
fetchPendingCounts();
|
||||
}
|
||||
}, [isDialogOpen, fetchPendingCounts]);
|
||||
|
||||
const debouncedFilter = useCallback(
|
||||
debounce((params: Filters) => {
|
||||
router.get(route('inventory.adjust.index'), params as any, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearchQuery(val);
|
||||
debouncedFilter({ search: val, warehouse_id: warehouseId, per_page: perPage });
|
||||
};
|
||||
|
||||
const handleWarehouseChange = (val: string) => {
|
||||
setWarehouseId(val);
|
||||
debouncedFilter({ search: searchQuery, warehouse_id: val, per_page: perPage });
|
||||
};
|
||||
|
||||
const handlePerPageChange = (val: string) => {
|
||||
setPerPage(val);
|
||||
debouncedFilter({ search: searchQuery, warehouse_id: warehouseId, per_page: val });
|
||||
};
|
||||
|
||||
const { data, setData, post, processing, reset } = useForm({
|
||||
count_doc_id: null as string | null,
|
||||
warehouse_id: '',
|
||||
reason: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
const handleCreate = (countDocId?: string) => {
|
||||
if (countDocId) {
|
||||
setData('count_doc_id', countDocId);
|
||||
router.post(route('inventory.adjust.store'), { count_doc_id: countDocId }, {
|
||||
onSuccess: () => setIsDialogOpen(false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
post(route('inventory.adjust.store'), {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
reset();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">草稿</Badge>;
|
||||
case 'posted':
|
||||
return <Badge className="bg-green-100 text-green-700 border-none">已過帳</Badge>;
|
||||
case 'voided':
|
||||
return <Badge variant="destructive" className="bg-red-100 text-red-700 border-none">已作廢</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '庫存盤調', href: route('inventory.adjust.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="庫存盤調" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 text-primary-main" />
|
||||
庫存盤調單
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">針對盤點差異進行庫存調整與過帳 (盤盈、盤虧、報廢等)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Context */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜尋單號、原因或備註..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10 pr-10 h-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => handleSearchChange('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warehouse Filter */}
|
||||
<SearchableSelect
|
||||
options={[
|
||||
{ value: '', label: '所有倉庫' },
|
||||
...warehouses.map(w => ({ value: w.id, label: w.name }))
|
||||
]}
|
||||
value={warehouseId}
|
||||
onValueChange={handleWarehouseChange}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full md:w-[200px] h-9"
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Can permission="inventory.adjust">
|
||||
<Button
|
||||
className="flex-1 md:flex-none button-filled-primary h-9"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增盤調單
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Container */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center font-medium text-grey-600">#</TableHead>
|
||||
<TableHead className="w-[180px] font-medium text-grey-600">單號</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">倉庫</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">調整原因</TableHead>
|
||||
<TableHead className="font-medium text-grey-600 text-center">狀態</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">建立者</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">建立時間</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">過帳時間</TableHead>
|
||||
<TableHead className="text-right font-medium text-grey-600">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{docs.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-32 text-center text-grey-400">
|
||||
尚無任何盤調單據
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
docs.data.map((doc: Doc, index: number) => (
|
||||
<TableRow
|
||||
key={doc.id}
|
||||
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
|
||||
onClick={() => router.visit(route('inventory.adjust.show', [doc.id]))}
|
||||
>
|
||||
<TableCell className="text-center text-grey-400 font-medium">
|
||||
{(docs.current_page - 1) * docs.per_page + index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold text-primary-main">
|
||||
{doc.doc_no}
|
||||
</TableCell>
|
||||
<TableCell className="text-grey-700">{doc.warehouse_name}</TableCell>
|
||||
<TableCell className="text-grey-600 max-w-[200px] truncate">{doc.reason}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
|
||||
<TableCell className="text-grey-600 text-sm">{doc.created_by}</TableCell>
|
||||
<TableCell className="text-grey-400 text-xs">{doc.created_at}</TableCell>
|
||||
<TableCell className="text-grey-400 text-xs">{doc.posted_at}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-primary-main hover:bg-primary-50 px-2"
|
||||
>
|
||||
{doc.status === 'posted' ? (
|
||||
<><Eye className="h-4 w-4 mr-1" /> 查閱</>
|
||||
) : (
|
||||
<><Pencil className="h-4 w-4 mr-1" /> 編輯</>
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "15", value: "15" },
|
||||
{ label: "30", value: "30" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">共 {docs?.total || 0} 筆紀錄</span>
|
||||
</div>
|
||||
<Pagination links={docs.links} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<Plus className="h-5 w-5 text-primary-main" />
|
||||
新增盤調單
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
請選擇一個已完成的盤點單來生成盤調項目,或手動建立。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Option 1: Scan/Select from Count Docs */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-grey-700">方法一:關聯盤點單 (推薦)</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-400" />
|
||||
<Input
|
||||
placeholder="掃描盤點單號或搜尋..."
|
||||
className="pl-9 h-11 border-primary-100 focus:ring-primary-main"
|
||||
value={scanSearch}
|
||||
onChange={(e) => {
|
||||
setScanSearch(e.target.value);
|
||||
fetchPendingCounts(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-height-[300px] overflow-y-auto rounded-md border border-grey-100 bg-grey-50">
|
||||
{loadingPending ? (
|
||||
<div className="p-8 text-center text-sm text-grey-400">載入中...</div>
|
||||
) : pendingCounts.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-grey-400">
|
||||
查無可供盤調的盤點單 (需為已完成狀態)
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-grey-100">
|
||||
{pendingCounts.map((c: any) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="p-3 hover:bg-white flex items-center justify-between cursor-pointer group transition-colors"
|
||||
onClick={() => handleCreate(c.id)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-bold text-grey-900 group-hover:text-primary-main">{c.doc_no}</p>
|
||||
<p className="text-xs text-grey-500">{c.warehouse_name} | 完成於: {c.completed_at}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="button-outlined-primary">
|
||||
選擇並載入
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t border-grey-200" /></div>
|
||||
<div className="relative flex justify-center text-xs uppercase"><span className="bg-white px-2 text-grey-400 font-medium">或</span></div>
|
||||
</div>
|
||||
|
||||
{/* Option 2: Manual (Optional, though less common in this flow) */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-grey-700">方法二:手動建立調整</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">選擇倉庫</Label>
|
||||
<SearchableSelect
|
||||
options={warehouses.map(w => ({ value: w.id, label: w.name }))}
|
||||
value={data.warehouse_id}
|
||||
onValueChange={(val) => setData('warehouse_id', val)}
|
||||
placeholder="選擇倉庫"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">調整原因</Label>
|
||||
<Input
|
||||
placeholder="例如: 報廢, 破損..."
|
||||
value={data.reason}
|
||||
onChange={(e) => setData('reason', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-gray-50 -mx-6 -mb-6 p-4 rounded-b-lg">
|
||||
<Button variant="ghost" onClick={() => setIsDialogOpen(false)}>取消</Button>
|
||||
<Button
|
||||
className="button-filled-primary"
|
||||
disabled={processing || !data.warehouse_id || !data.reason}
|
||||
onClick={() => handleCreate()}
|
||||
>
|
||||
建立手動盤調
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
512
resources/js/Pages/Inventory/Adjust/Show.tsx
Normal file
512
resources/js/Pages/Inventory/Adjust/Show.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Save, CheckCircle, Trash2, ArrowLeft, Plus, X, Search, FileText } from "lucide-react";
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import axios from 'axios';
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
|
||||
interface AdjItem {
|
||||
id?: string;
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
product_code: string;
|
||||
batch_number: string | null;
|
||||
unit: string;
|
||||
qty_before: number;
|
||||
adjust_qty: number | string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface AdjDoc {
|
||||
id: string;
|
||||
doc_no: string;
|
||||
warehouse_id: string;
|
||||
warehouse_name: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
remarks: string;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
count_doc_id?: string;
|
||||
count_doc_no?: string;
|
||||
items: AdjItem[];
|
||||
}
|
||||
|
||||
export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
const isDraft = doc.status === 'draft';
|
||||
|
||||
// Main Form using Inertia useForm
|
||||
const { data, setData, put, delete: destroy, processing } = useForm({
|
||||
reason: doc.reason,
|
||||
remarks: doc.remarks || '',
|
||||
items: doc.items || [],
|
||||
action: 'save',
|
||||
});
|
||||
|
||||
|
||||
// Helper to add new item
|
||||
const addItem = (product: any, batchNumber: string | null) => {
|
||||
// Check if exists
|
||||
const exists = data.items.find(i =>
|
||||
i.product_id === String(product.id) &&
|
||||
i.batch_number === batchNumber
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
alert('此商品與批號已在列表中');
|
||||
return;
|
||||
}
|
||||
|
||||
setData('items', [
|
||||
...data.items,
|
||||
{
|
||||
product_id: String(product.id),
|
||||
product_name: product.name,
|
||||
product_code: product.code,
|
||||
unit: product.unit,
|
||||
batch_number: batchNumber,
|
||||
qty_before: product.qty || 0, // Not fetched dynamically for now, or could fetch via API
|
||||
adjust_qty: 0,
|
||||
notes: '',
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newItems = [...data.items];
|
||||
newItems.splice(index, 1);
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: keyof AdjItem, value: any) => {
|
||||
const newItems = [...data.items];
|
||||
(newItems[index] as any)[field] = value;
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setData('action', 'save');
|
||||
put(route('inventory.adjust.update', [doc.id]), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePost = () => {
|
||||
// Validate
|
||||
if (data.items.length === 0) {
|
||||
alert('請至少加入一個調整項目');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasZero = data.items.some(i => Number(i.adjust_qty) === 0);
|
||||
if (hasZero && !confirm('部分項目的調整數量為 0,確定要繼續嗎?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm('確定要過帳嗎?過帳後將無法修改,並直接影響庫存。')) {
|
||||
router.visit(route('inventory.adjust.update', [doc.id]), {
|
||||
method: 'put',
|
||||
data: { ...data, action: 'post' } as any,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
destroy(route('inventory.adjust.destroy', [doc.id]));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '庫存盤調', href: route('inventory.adjust.index') },
|
||||
{ label: doc.doc_no, href: route('inventory.adjust.show', [doc.id]), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title={`盤調單 ${doc.doc_no}`} />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||
<div className="space-y-6">
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 border-grey-200"
|
||||
onClick={() => router.visit(route('inventory.adjust.index'))}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-grey-600" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-3">
|
||||
{doc.doc_no}
|
||||
{isDraft ? (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">草稿</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-100 text-green-700 border-none">已過帳</Badge>
|
||||
)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-grey-500">
|
||||
<span className="flex items-center gap-1"><CheckCircle className="h-3 w-3" /> 倉庫: {doc.warehouse_name}</span>
|
||||
<span>|</span>
|
||||
<span>建立者: {doc.created_by}</span>
|
||||
<span>|</span>
|
||||
<span>時間: {doc.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isDraft && (
|
||||
<Can permission="inventory.adjust">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" className="text-red-500 hover:bg-red-50 hover:text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" /> 刪除單據
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要刪除此盤調單嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此動作將會永久移除本張草稿,且無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">確認刪除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Button variant="outline" className="border-primary-200 text-primary-main hover:bg-primary-50" onClick={handleSave} disabled={processing}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
儲存草稿
|
||||
</Button>
|
||||
|
||||
<Button className="button-filled-primary" onClick={handlePost} disabled={processing}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
確認過帳
|
||||
</Button>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 shadow-sm border-grey-100">
|
||||
<CardHeader className="bg-grey-50/50 border-b border-grey-100 py-3">
|
||||
<CardTitle className="text-sm font-semibold text-grey-600">明細備註</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider">調整原因</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
value={data.reason}
|
||||
onChange={e => setData('reason', e.target.value)}
|
||||
className="focus:ring-primary-main"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-grey-900 font-medium">{data.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider">備註說明</Label>
|
||||
{isDraft ? (
|
||||
<Textarea
|
||||
value={data.remarks}
|
||||
onChange={e => setData('remarks', e.target.value)}
|
||||
rows={1}
|
||||
className="focus:ring-primary-main"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-grey-600">{data.remarks || '-'}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm border-grey-100 bg-primary-50/30">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm font-semibold text-primary-main">來源單據</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-[10px] font-bold text-primary-main/60 uppercase">關聯盤點單</Label>
|
||||
{doc.count_doc_id ? (
|
||||
<div className="mt-1">
|
||||
<Link
|
||||
href={route('inventory.count.show', [doc.count_doc_id])}
|
||||
className="text-primary-main font-bold hover:underline flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{doc.count_doc_no || '檢視盤點單'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-grey-400 italic text-sm mt-1">手動建立,無來源盤點單</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2 border-t border-primary-100">
|
||||
<Label className="text-[10px] font-bold text-primary-main/60 uppercase">倉庫位置</Label>
|
||||
<p className="font-bold text-grey-900">{doc.warehouse_name}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-grey-900">調整項目清單</h3>
|
||||
{isDraft && (
|
||||
<ProductSearchDialog
|
||||
warehouseId={doc.warehouse_id}
|
||||
onSelect={(product, batch) => addItem(product, batch)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center font-medium text-grey-600">#</TableHead>
|
||||
<TableHead className="pl-4 font-medium text-grey-600">商品資訊</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
||||
<TableHead className="w-24 text-center font-medium text-grey-600">單位</TableHead>
|
||||
<TableHead className="w-32 text-right font-medium text-grey-600 text-primary-main">調整前庫存</TableHead>
|
||||
<TableHead className="w-40 text-right font-medium text-grey-600">調整數量 (+/-)</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">備註說明</TableHead>
|
||||
{isDraft && <TableHead className="w-[80px]"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isDraft ? 8 : 7} className="h-32 text-center text-grey-400">
|
||||
尚未載入任何調整項目
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((item, index) => (
|
||||
<TableRow
|
||||
key={`${item.product_id}-${item.batch_number}-${index}`}
|
||||
className="group hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<TableCell className="text-center text-grey-400 font-medium">{index + 1}</TableCell>
|
||||
<TableCell className="pl-4">
|
||||
<div className="font-bold text-grey-900">{item.product_name}</div>
|
||||
<div className="text-xs text-grey-500 font-mono">{item.product_code}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-grey-600">{item.batch_number || '-'}</TableCell>
|
||||
<TableCell className="text-center text-grey-500">{item.unit}</TableCell>
|
||||
<TableCell className="text-right font-medium text-grey-400">
|
||||
{item.qty_before}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="number"
|
||||
className="text-right h-9 border-grey-200 focus:ring-primary-main"
|
||||
value={item.adjust_qty}
|
||||
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className={`font-bold ${Number(item.adjust_qty) > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{Number(item.adjust_qty) > 0 ? '+' : ''}{item.adjust_qty}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
className="h-9 border-grey-200 focus:ring-primary-main"
|
||||
value={item.notes || ''}
|
||||
onChange={e => updateItem(index, 'notes', e.target.value)}
|
||||
placeholder="輸入備註..."
|
||||
/>
|
||||
) : (
|
||||
<span className="text-grey-600 text-sm">{item.notes || '-'}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{isDraft && (
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0"
|
||||
onClick={() => removeItem(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-gray-50/80 border border-dashed border-grey-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-primary-main mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-grey-500 leading-relaxed">
|
||||
<p className="font-bold text-grey-700">操作導引:</p>
|
||||
<ul className="list-disc ml-4 space-y-1 mt-1">
|
||||
<li><strong>調整數量 (+/-):</strong>輸入正數 (+) 表示增加庫存 (盤盈、溢收);輸入負數 (-) 表示扣減庫存 (盤虧、報廢、損耗)。</li>
|
||||
<li><strong>批號控管:</strong>若商品啟用批號管理,請務必確認調整項目對應的批號是否正確。</li>
|
||||
<li><strong>過帳生效:</strong>點擊「確認過帳」後,單據將轉為唯讀,並立即更新即時庫存明細與異動紀錄。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple internal component for product search
|
||||
function ProductSearchDialog({ onSelect }: { warehouseId: string, onSelect: (p: any, b: string | null) => void }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
fetchProducts();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Using existing API logic from Goods Receipts or creating a flexible one
|
||||
// Using count docs logic for now if specific endpoint not available,
|
||||
// but `goods-receipts.search-products` is a good bet for general product search.
|
||||
const res = await axios.get(route('goods-receipts.search-products'), { params: { query: search } });
|
||||
setResults(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-primary-100 text-primary-main hover:bg-primary-50 px-3 flex items-center gap-2"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增調整項目
|
||||
</Button>
|
||||
<DialogContent className="sm:max-w-[500px] p-0 overflow-hidden border-none shadow-2xl">
|
||||
<DialogHeader className="bg-primary-main p-6">
|
||||
<DialogTitle className="text-white text-xl flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
搜尋並加入商品
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-grey-400" />
|
||||
<Input
|
||||
placeholder="輸入商品名稱、代號或條碼..."
|
||||
className="pl-11 h-12 border-grey-200 rounded-xl text-lg focus:ring-primary-main transition-all"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-[350px] overflow-y-auto rounded-xl border border-grey-100 bg-grey-50">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-grey-400 space-y-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-main"></div>
|
||||
<span className="text-sm font-medium">搜尋中...</span>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-grey-400 p-8 text-center space-y-2">
|
||||
<Search className="h-10 w-10 opacity-20" />
|
||||
<p className="text-sm">請輸入商品關鍵字開始搜尋</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-grey-100">
|
||||
{results.map(product => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="p-4 hover:bg-white cursor-pointer flex justify-between items-center group transition-colors"
|
||||
onClick={() => {
|
||||
onSelect(product, null);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setResults([]);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold text-grey-900 group-hover:text-primary-main transition-colors">{product.name}</div>
|
||||
<div className="text-xs text-grey-500 font-mono tracking-tight">{product.code}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant="outline" className="text-[10px] h-5 border-grey-200">{product.unit || '單位'}</Badge>
|
||||
<span className="text-[10px] text-grey-400">點擊加入</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
324
resources/js/Pages/Inventory/Count/Index.tsx
Normal file
324
resources/js/Pages/Inventory/Count/Index.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { debounce } from "lodash";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Card, CardContent } from '@/Components/ui/card';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
Pencil
|
||||
} from 'lucide-react';
|
||||
import Pagination from '@/Components/shared/Pagination';
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
|
||||
export default function Index({ auth, docs, warehouses, filters }: any) {
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const { data, setData, post, processing, reset, errors } = useForm({
|
||||
warehouse_id: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
|
||||
const [perPage, setPerPage] = useState(filters.per_page || "10");
|
||||
|
||||
// Sync state with props
|
||||
useEffect(() => {
|
||||
setSearchTerm(filters.search || "");
|
||||
setWarehouseFilter(filters.warehouse_id || "all");
|
||||
setPerPage(filters.per_page || "10");
|
||||
}, [filters]);
|
||||
|
||||
// Debounced Search Handler
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string, warehouse: string) => {
|
||||
router.get(
|
||||
route('inventory.count.index'),
|
||||
{ ...filters, search: term, warehouse_id: warehouse === "all" ? "" : warehouse },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
}, 500),
|
||||
[filters]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
debouncedSearch(term, warehouseFilter);
|
||||
};
|
||||
|
||||
const handleFilterChange = (value: string) => {
|
||||
setWarehouseFilter(value);
|
||||
router.get(
|
||||
route('inventory.count.index'),
|
||||
{ ...filters, warehouse_id: value === "all" ? "" : value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm("");
|
||||
router.get(
|
||||
route('inventory.count.index'),
|
||||
{ ...filters, search: "", warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route('inventory.count.index'),
|
||||
{ ...filters, per_page: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = (e) => {
|
||||
e.preventDefault();
|
||||
post(route('inventory.count.store'), {
|
||||
onSuccess: () => {
|
||||
setIsCreateDialogOpen(false);
|
||||
reset();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">草稿</Badge>;
|
||||
case 'counting':
|
||||
return <Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>;
|
||||
case 'cancelled':
|
||||
return <Badge variant="destructive">已取消</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '商品與庫存與管理', href: '#' },
|
||||
{ label: '庫存盤點', href: route('inventory.count.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="庫存盤點" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 text-primary-main" />
|
||||
庫存盤點管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
建立與管理倉庫盤點單,執行定期庫存核對。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋盤點單號、備註..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10 pr-10 h-9"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warehouse Filter */}
|
||||
<SearchableSelect
|
||||
value={warehouseFilter}
|
||||
onValueChange={handleFilterChange}
|
||||
options={[
|
||||
{ label: "所有倉庫", value: "all" },
|
||||
...warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))
|
||||
]}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full md:w-[200px] h-9"
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Can permission="inventory.view">
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex-1 md:flex-none button-filled-primary">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增盤點單
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>建立新盤點單</DialogTitle>
|
||||
<DialogDescription>
|
||||
建立後將自動對該倉庫庫存進行快照,請確認倉庫作業已暫停。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="warehouse">選擇倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={data.warehouse_id}
|
||||
onValueChange={(val) => setData('warehouse_id', val)}
|
||||
options={warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
|
||||
placeholder="請選擇倉庫"
|
||||
className="h-9"
|
||||
/>
|
||||
{errors.warehouse_id && <p className="text-red-500 text-sm">{errors.warehouse_id}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remarks">備註</Label>
|
||||
<Input
|
||||
id="remarks"
|
||||
className="h-9"
|
||||
value={data.remarks}
|
||||
onChange={(e) => setData('remarks', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" className="button-filled-primary" disabled={processing || !data.warehouse_id}>
|
||||
確認建立
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>單號</TableHead>
|
||||
<TableHead>倉庫</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>快照時間</TableHead>
|
||||
<TableHead>完成時間</TableHead>
|
||||
<TableHead>建立人員</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{docs.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
|
||||
尚無盤點紀錄
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
docs.data.map((doc, index) => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{(docs.current_page - 1) * docs.per_page + index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-primary-main">{doc.doc_no}</TableCell>
|
||||
<TableCell>{doc.warehouse_name}</TableCell>
|
||||
<TableCell>{getStatusBadge(doc.status)}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{doc.snapshot_date}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{doc.completed_at || '-'}</TableCell>
|
||||
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Can permission="inventory.view">
|
||||
<Link href={route('inventory.count.show', [doc.id])}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title={doc.status === 'completed' ? '查閱' : '盤點'}
|
||||
>
|
||||
{doc.status === 'completed' ? (
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
) : (
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{doc.status === 'completed' ? '查閱' : '盤點'}
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">共 {docs.total} 筆紀錄</span>
|
||||
</div>
|
||||
<Pagination links={docs.links} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
276
resources/js/Pages/Inventory/Count/Show.tsx
Normal file
276
resources/js/Pages/Inventory/Count/Show.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Save, CheckCircle, Printer, Trash2, ClipboardCheck } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog"
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
|
||||
export default function Show({ doc }: any) {
|
||||
// Transform items to form data structure
|
||||
const { data, setData, put, delete: destroy, processing, transform } = useForm({
|
||||
items: doc.items.map((item: any) => ({
|
||||
id: item.id,
|
||||
counted_qty: item.counted_qty,
|
||||
notes: item.notes || '',
|
||||
})),
|
||||
action: 'save', // 'save' or 'complete'
|
||||
});
|
||||
|
||||
// Helper to update local form data
|
||||
const updateItem = (index: number, field: string, value: any) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index][field] = value;
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const handleSubmit = (action: string) => {
|
||||
transform((data) => ({
|
||||
...data,
|
||||
action: action,
|
||||
}));
|
||||
put(route('inventory.count.update', [doc.id]));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
destroy(route('inventory.count.destroy', [doc.id]));
|
||||
};
|
||||
|
||||
const isCompleted = doc.status === 'completed';
|
||||
|
||||
// Calculate progress
|
||||
const totalItems = doc.items.length;
|
||||
const countedItems = data.items.filter((i: any) => i.counted_qty !== '' && i.counted_qty !== null).length;
|
||||
const progress = Math.round((countedItems / totalItems) * 100) || 0;
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '庫存盤點', href: route('inventory.count.index') },
|
||||
{ label: `盤點單: ${doc.doc_no}`, href: route('inventory.count.show', [doc.id]), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title={`盤點單 ${doc.doc_no}`} />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 text-primary-main" />
|
||||
盤點單: {doc.doc_no}
|
||||
</h1>
|
||||
{doc.status === 'completed' ? (
|
||||
<Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>
|
||||
) : (
|
||||
<Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">
|
||||
倉庫: {doc.warehouse_name} <span className="mx-2">|</span> 建立人: {doc.created_by} <span className="mx-2">|</span> 快照時間: {doc.snapshot_date}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isCompleted && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Can permission="inventory.view">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
作廢盤點單
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要作廢此盤點單嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此動作無法復原。作廢後請重新建立盤點單。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">確認作廢</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
onClick={() => handleSubmit('save')}
|
||||
disabled={processing}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
暫存進度
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="button-filled-primary"
|
||||
onClick={() => handleSubmit('complete')}
|
||||
disabled={processing}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
完成盤點
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
列印報表
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCompleted && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 font-medium">盤點進度: {countedItems} / {totalItems} 項目</span>
|
||||
<span className="font-bold text-primary-main">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-primary-main h-2 rounded-full transition-all duration-300" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-grey-900">盤點明細</h3>
|
||||
<p className="text-sm text-grey-500">
|
||||
請輸入每個項目的「實盤數量」。若有差異系統將自動計算。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center font-medium text-grey-600">#</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">商品名稱 / 代號</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
||||
<TableHead className="text-right font-medium text-grey-600">系統庫存</TableHead>
|
||||
<TableHead className="text-right w-32 font-medium text-grey-600">實盤數量</TableHead>
|
||||
<TableHead className="text-right font-medium text-grey-600">差異</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">單位</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">備註</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{doc.items.map((item: any, index: number) => {
|
||||
const formItem = data.items[index];
|
||||
const diff = formItem.counted_qty !== '' && formItem.counted_qty !== null
|
||||
? (parseFloat(formItem.counted_qty) - item.system_qty)
|
||||
: 0;
|
||||
const hasDiff = Math.abs(diff) > 0.0001;
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} className={hasDiff && formItem.counted_qty !== '' ? "bg-red-50/30" : ""}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-gray-900">{item.product_name}</span>
|
||||
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
||||
<TableCell className="text-right font-medium">{item.system_qty.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right px-1 py-3">
|
||||
{isCompleted ? (
|
||||
<span className="font-semibold mr-2">{item.counted_qty}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formItem.counted_qty ?? ''}
|
||||
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
||||
onWheel={(e: any) => e.target.blur()}
|
||||
disabled={processing}
|
||||
className="h-9 text-right font-medium focus:ring-primary-main"
|
||||
placeholder="盤點..."
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className={`font-bold ${!hasDiff
|
||||
? 'text-gray-400'
|
||||
: diff > 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{formItem.counted_qty !== '' && formItem.counted_qty !== null
|
||||
? diff.toFixed(2)
|
||||
: '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</TableCell>
|
||||
<TableCell className="px-1">
|
||||
{isCompleted ? (
|
||||
<span className="text-sm text-gray-600">{item.notes}</span>
|
||||
) : (
|
||||
<Input
|
||||
value={formItem.notes}
|
||||
onChange={(e) => updateItem(index, 'notes', e.target.value)}
|
||||
disabled={processing}
|
||||
className="h-9 text-sm"
|
||||
placeholder="備註..."
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-gray-50/80 border border-dashed border-grey-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<Save className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1 text-sm">操作導引</h4>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
資料會自動保存盤點人與時間。若尚未盤點完,您可以點擊「暫存進度」稍後繼續。
|
||||
確認所有項目資料正確後,請點擊「完成盤點」結束盤點作業。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
763
resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
Normal file
763
resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
Normal file
@@ -0,0 +1,763 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { SearchableSelect } from '@/Components/ui/searchable-select';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
|
||||
import {
|
||||
Search,
|
||||
Trash2,
|
||||
Calendar as CalendarIcon,
|
||||
Save,
|
||||
ArrowLeft,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { PurchaseOrderStatus } from '@/types/purchase-order';
|
||||
import { STATUS_CONFIG } from '@/constants/purchase-order';
|
||||
|
||||
|
||||
|
||||
interface BatchItem {
|
||||
inventoryId: string;
|
||||
batchNumber: string;
|
||||
originCountry: string;
|
||||
expiryDate: string | null;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 待進貨採購單 Item 介面
|
||||
interface PendingPOItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_code: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
received_quantity: number;
|
||||
remaining: number;
|
||||
unit_price: number;
|
||||
batchMode?: 'existing' | 'new';
|
||||
originCountry?: string; // For new batch generation
|
||||
}
|
||||
|
||||
// 待進貨採購單介面
|
||||
interface PendingPO {
|
||||
id: number;
|
||||
code: string;
|
||||
status: PurchaseOrderStatus;
|
||||
vendor_id: number;
|
||||
vendor_name: string;
|
||||
warehouse_id: number | null;
|
||||
order_date: string;
|
||||
items: PendingPOItem[];
|
||||
}
|
||||
|
||||
// 廠商介面
|
||||
interface Vendor {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
warehouses: { id: number; name: string; type: string }[];
|
||||
pendingPurchaseOrders: PendingPO[];
|
||||
vendors: Vendor[];
|
||||
}
|
||||
|
||||
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors }: Props) {
|
||||
const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
|
||||
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Manual Product Search States
|
||||
const [productSearch, setProductSearch] = useState('');
|
||||
const [foundProducts, setFoundProducts] = useState<any[]>([]);
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
type: 'standard', // 'standard', 'miscellaneous', 'other'
|
||||
warehouse_id: '',
|
||||
purchase_order_id: '',
|
||||
vendor_id: '',
|
||||
received_date: new Date().toISOString().split('T')[0],
|
||||
remarks: '',
|
||||
items: [] as any[],
|
||||
});
|
||||
|
||||
// 搜尋商品 API(用於雜項入庫/其他類型)
|
||||
const searchProducts = async () => {
|
||||
if (!productSearch) return;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await axios.get(route('goods-receipts.search-products'), {
|
||||
params: { query: productSearch },
|
||||
});
|
||||
setFoundProducts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to search products', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 選擇採購單
|
||||
const handleSelectPO = (po: PendingPO) => {
|
||||
setSelectedPO(po);
|
||||
// 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
|
||||
const pendingItems = po.items.map((item) => ({
|
||||
product_id: item.product_id,
|
||||
purchase_order_item_id: item.id,
|
||||
product_name: item.product_name,
|
||||
sku: item.product_code,
|
||||
unit: item.unit,
|
||||
quantity_ordered: item.quantity,
|
||||
quantity_received_so_far: item.received_quantity,
|
||||
quantity_received: item.remaining, // 預填剩餘量
|
||||
unit_price: item.unit_price,
|
||||
batch_number: '',
|
||||
batchMode: 'new',
|
||||
originCountry: 'TW',
|
||||
expiry_date: '',
|
||||
}));
|
||||
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
purchase_order_id: po.id.toString(),
|
||||
vendor_id: po.vendor_id.toString(),
|
||||
warehouse_id: po.warehouse_id ? po.warehouse_id.toString() : prev.warehouse_id,
|
||||
items: pendingItems,
|
||||
}));
|
||||
};
|
||||
|
||||
// 選擇廠商(雜項入庫/其他)
|
||||
const handleSelectVendor = (vendorId: string) => {
|
||||
const vendor = vendors.find(v => v.id.toString() === vendorId);
|
||||
if (vendor) {
|
||||
setSelectedVendor(vendor);
|
||||
setData('vendor_id', vendor.id.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProduct = (product: any) => {
|
||||
const newItem = {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
sku: product.code,
|
||||
quantity_received: 0,
|
||||
unit_price: product.price || 0,
|
||||
batch_number: '',
|
||||
batchMode: 'new',
|
||||
originCountry: 'TW',
|
||||
expiry_date: '',
|
||||
};
|
||||
setData('items', [...data.items, newItem]);
|
||||
setFoundProducts([]);
|
||||
setProductSearch('');
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newItems = [...data.items];
|
||||
newItems.splice(index, 1);
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: string, value: any) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
// Generate batch preview (Added)
|
||||
const getBatchPreview = (productId: number, productCode: string, country: string, dateStr: string) => {
|
||||
if (!productCode || !productId) return "--";
|
||||
try {
|
||||
const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
|
||||
const [yyyy, mm, dd] = datePart.split('-');
|
||||
const dateFormatted = `${yyyy}${mm}${dd}`;
|
||||
|
||||
const seqKey = `${productId}-${country}-${datePart}`;
|
||||
// Handle sequence. Note: nextSequences values are numbers.
|
||||
const seq = nextSequences[seqKey]?.toString().padStart(2, '0') || "01";
|
||||
|
||||
return `${productCode}-${country}-${dateFormatted}-${seq}`;
|
||||
} catch (e) {
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
// Batch management
|
||||
const [batchesCache, setBatchesCache] = useState<Record<string, BatchItem[]>>({});
|
||||
const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
|
||||
|
||||
// Fetch batches and sequence for a product
|
||||
const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
|
||||
if (!data.warehouse_id) return;
|
||||
const cacheKey = `${productId}-${data.warehouse_id}`;
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const targetDate = dateStr || data.received_date || today;
|
||||
|
||||
// Adjust API endpoint to match AddInventory logic
|
||||
// Assuming GoodsReceiptController or existing WarehouseController can handle this.
|
||||
// Using the same endpoint as AddInventory: /api/warehouses/{id}/inventory/batches/{productId}
|
||||
const response = await axios.get(
|
||||
`/api/warehouses/${data.warehouse_id}/inventory/batches/${productId}`,
|
||||
{
|
||||
params: {
|
||||
origin_country: country,
|
||||
arrivalDate: targetDate
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data) {
|
||||
// Update existing batches list
|
||||
if (response.data.batches) {
|
||||
setBatchesCache(prev => ({
|
||||
...prev,
|
||||
[cacheKey]: response.data.batches
|
||||
}));
|
||||
}
|
||||
|
||||
// Update next sequence for new batch generation
|
||||
if (response.data.nextSequence !== undefined) {
|
||||
const seqKey = `${productId}-${country}-${targetDate}`;
|
||||
setNextSequences(prev => ({
|
||||
...prev,
|
||||
[seqKey]: parseInt(response.data.nextSequence)
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch batches", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger batch fetch when relevant fields change
|
||||
useEffect(() => {
|
||||
data.items.forEach(item => {
|
||||
if (item.product_id && data.warehouse_id) {
|
||||
const country = item.originCountry || 'TW';
|
||||
const date = data.received_date;
|
||||
fetchProductBatches(item.product_id, country, date);
|
||||
}
|
||||
});
|
||||
}, [data.items.length, data.warehouse_id, data.received_date, JSON.stringify(data.items.map(i => i.originCountry))]);
|
||||
|
||||
useEffect(() => {
|
||||
data.items.forEach((item, index) => {
|
||||
if (item.batchMode === 'new' && item.originCountry && data.received_date) {
|
||||
const country = item.originCountry;
|
||||
// Use date from form or today
|
||||
const dateStr = data.received_date || new Date().toISOString().split('T')[0];
|
||||
const seqKey = `${item.product_id}-${country}-${dateStr}`;
|
||||
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
|
||||
|
||||
// Only generate if we have a sequence (or default)
|
||||
// Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences
|
||||
|
||||
const datePart = dateStr.replace(/-/g, '');
|
||||
const generatedBatch = `${item.sku}-${country}-${datePart}-${seq}`;
|
||||
|
||||
if (item.batch_number !== generatedBatch) {
|
||||
// Update WITHOUT triggering re-render loop
|
||||
// Need a way to update item silently or check condition carefully
|
||||
// Using setBatchNumber might trigger this effect again but value will be same.
|
||||
const newItems = [...data.items];
|
||||
newItems[index].batch_number = generatedBatch;
|
||||
setData('items', newItems);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [nextSequences, JSON.stringify(data.items.map(i => ({ m: i.batchMode, c: i.originCountry, s: i.sku, p: i.product_id }))), data.received_date]);
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('goods-receipts.store'));
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '供應鏈管理', href: '#' },
|
||||
{ label: '進貨單管理', href: route('goods-receipts.index') },
|
||||
{ label: '新增進貨單', href: route('goods-receipts.create'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="新增進貨單" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href={route('goods-receipts.index')}>
|
||||
<Button variant="outline" className="gap-2 mb-4 w-fit">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回進貨單
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Package className="h-6 w-6 text-primary-main" />
|
||||
新增進貨單
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
建立新的進貨單並入庫
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Step 0: Select Type */}
|
||||
<div className="bg-white rounded-lg border shadow-sm p-6">
|
||||
<Label className="text-sm font-bold mb-3 block">選擇單據類型</Label>
|
||||
<div className="flex gap-4">
|
||||
{[
|
||||
{ id: 'standard', label: '標準採購', desc: '從採購單帶入' },
|
||||
{ id: 'miscellaneous', label: '雜項入庫', desc: '非採購之入庫' },
|
||||
{ id: 'other', label: '其他', desc: '其他原因入庫' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
type: t.id,
|
||||
purchase_order_id: '',
|
||||
items: [],
|
||||
vendor_id: t.id === 'standard' ? prev.vendor_id : '',
|
||||
}));
|
||||
setSelectedPO(null);
|
||||
if (t.id !== 'standard') setSelectedVendor(null);
|
||||
}}
|
||||
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
|
||||
? 'border-primary-main bg-primary-main/5'
|
||||
: 'border-gray-100 hover:border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
|
||||
{t.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Source Selection */}
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${(data.type === 'standard' ? !!selectedPO : !!selectedVendor)
|
||||
? 'bg-green-500 text-white shadow-sm' : 'bg-primary-main text-white shadow-sm'}`}>
|
||||
{(data.type === 'standard' ? selectedPO : selectedVendor) ? '✓' : '1'}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-800">
|
||||
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{data.type === 'standard' ? (
|
||||
!selectedPO ? (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium text-gray-700">請選擇待進貨的採購單</Label>
|
||||
|
||||
{pendingPurchaseOrders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
|
||||
目前沒有待進貨的採購單
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead>採購單號</TableHead>
|
||||
<TableHead>供應商</TableHead>
|
||||
<TableHead className="text-center">狀態</TableHead>
|
||||
<TableHead className="text-center">待收項目</TableHead>
|
||||
<TableHead className="w-[100px] text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pendingPurchaseOrders.map((po) => (
|
||||
<TableRow key={po.id} className="hover:bg-gray-50/50">
|
||||
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
|
||||
<TableCell>{po.vendor_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={STATUS_CONFIG[po.status]?.variant || 'outline'}>
|
||||
{STATUS_CONFIG[po.status]?.label || po.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-gray-600">
|
||||
{po.items.length} 項
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-filled-primary">
|
||||
選擇
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
|
||||
<div className="flex gap-8">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">已選採購單</span>
|
||||
<span className="font-bold text-primary-main">{selectedPO.code}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">供應商</span>
|
||||
<span className="font-bold text-gray-800">{selectedPO.vendor_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">待收項目</span>
|
||||
<span className="font-bold text-gray-800">{selectedPO.items.length} 項</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
!selectedVendor ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">請選擇供應商</Label>
|
||||
<SearchableSelect
|
||||
value=""
|
||||
onValueChange={handleSelectVendor}
|
||||
options={vendors.map(v => ({
|
||||
label: `${v.name} (${v.code})`,
|
||||
value: v.id.toString()
|
||||
}))}
|
||||
placeholder="選擇供應商..."
|
||||
searchPlaceholder="搜尋供應商..."
|
||||
className="h-9 w-full max-w-md"
|
||||
/>
|
||||
</div>
|
||||
{vendors.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
|
||||
目前沒有可選擇的供應商
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
|
||||
<div className="flex gap-8">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">已選供應商</span>
|
||||
<span className="font-bold text-primary-main">{selectedVendor.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">供應商代號</span>
|
||||
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Details & Items */}
|
||||
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
|
||||
<h2 className="text-lg font-bold text-gray-800">進貨資訊與明細</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="warehouse_id">收貨倉庫 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.warehouse_id}
|
||||
onValueChange={(val) => setData('warehouse_id', val)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="選擇倉庫" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map(w => (
|
||||
<SelectItem key={w.id} value={w.id.toString()}>{w.name} ({w.type})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.warehouse_id && <p className="text-red-500 text-xs">{errors.warehouse_id}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="received_date">進貨日期 <span className="text-red-500">*</span></Label>
|
||||
<div className="relative">
|
||||
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={data.received_date}
|
||||
onChange={(e) => setData('received_date', e.target.value)}
|
||||
className="pl-9 h-9 block w-full"
|
||||
/>
|
||||
</div>
|
||||
{errors.received_date && <p className="text-red-500 text-xs">{errors.received_date}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remarks">備註</Label>
|
||||
<Input
|
||||
value={data.remarks}
|
||||
onChange={(e) => setData('remarks', e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="選填..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-gray-700">商品明細</h3>
|
||||
{data.type !== 'standard' && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜尋商品加入..."
|
||||
value={productSearch}
|
||||
onChange={(e) => setProductSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && searchProducts()}
|
||||
className="h-9 w-64 pl-9"
|
||||
/>
|
||||
{foundProducts.length > 0 && (
|
||||
<div className="absolute top-10 left-0 w-full bg-white border rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
|
||||
{foundProducts.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleAddProduct(p)}
|
||||
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-0 flex flex-col"
|
||||
>
|
||||
<span className="font-bold text-sm">{p.name}</span>
|
||||
<span className="text-xs text-gray-500">{p.code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={searchProducts} disabled={isSearching} size="sm" className="button-filled-primary h-9">
|
||||
加入
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calculated Totals for usage in Table Footer or Summary */}
|
||||
{(() => {
|
||||
const subTotal = data.items.reduce((acc, item) => {
|
||||
const qty = parseFloat(item.quantity_received) || 0;
|
||||
const price = parseFloat(item.unit_price) || 0;
|
||||
return acc + (qty * price);
|
||||
}, 0);
|
||||
const taxAmount = Math.round(subTotal * 0.05);
|
||||
const grandTotal = subTotal + taxAmount;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">商品資訊</TableHead>
|
||||
<TableHead className="w-[80px] text-center">總數量</TableHead>
|
||||
<TableHead className="w-[80px] text-center">待收貨</TableHead>
|
||||
<TableHead className="w-[120px]">本次收貨 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[200px]">批號設定 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[150px]">效期</TableHead>
|
||||
<TableHead className="w-[80px] text-right">小計</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-gray-400 italic">
|
||||
尚無明細,請搜尋商品加入。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((item, index) => {
|
||||
const errorKey = `items.${index}.quantity_received` as keyof typeof errors;
|
||||
const itemTotal = (parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price || 0));
|
||||
|
||||
return (
|
||||
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
|
||||
{/* Product Info */}
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{item.product_name}</span>
|
||||
<span className="text-xs text-gray-500">{item.sku}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Total Quantity */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{Math.round(item.quantity_ordered)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Remaining */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-900 font-medium text-sm">
|
||||
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Received Quantity */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={item.quantity_received}
|
||||
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
|
||||
className={`w-full ${(errors as any)[errorKey] ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{(errors as any)[errorKey] && (
|
||||
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Batch Settings */}
|
||||
<TableCell>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={item.originCountry || 'TW'}
|
||||
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
|
||||
placeholder="產地"
|
||||
maxLength={2}
|
||||
className="w-16 text-center px-1"
|
||||
/>
|
||||
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate">
|
||||
{getBatchPreview(item.product_id, item.sku, item.originCountry || 'TW', data.received_date)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Expiry Date */}
|
||||
<TableCell>
|
||||
<div className="relative">
|
||||
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={item.expiry_date}
|
||||
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
|
||||
className={`pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
|
||||
disabled={item.batchMode === 'existing'}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Subtotal */}
|
||||
<TableCell className="text-right font-medium">
|
||||
${itemTotal.toLocaleString()}
|
||||
</TableCell>
|
||||
|
||||
{/* Actions */}
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeItem(index)}
|
||||
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">小計</span>
|
||||
<span className="text-lg font-bold text-gray-700">${subTotal.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">稅額 (5%)</span>
|
||||
<span className="text-lg font-bold text-gray-700">${taxAmount.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-primary/10 w-full my-1"></div>
|
||||
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">總計金額</span>
|
||||
<span className="text-2xl font-black text-primary">
|
||||
${grandTotal.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Bar */}
|
||||
<div className="flex items-center justify-end gap-4 py-6 border-t border-gray-100 mt-6">
|
||||
<Button variant="ghost" className="h-11 px-6 text-gray-500 hover:text-gray-700" onClick={() => window.history.back()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
onClick={submit}
|
||||
disabled={processing || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
|
||||
>
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
{processing ? '處理中...' : '確認進貨'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout >
|
||||
);
|
||||
}
|
||||
328
resources/js/Pages/Inventory/GoodsReceipt/Index.tsx
Normal file
328
resources/js/Pages/Inventory/GoodsReceipt/Index.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Plus, Search, FileText, RotateCcw, Calendar, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { SearchableSelect } from '@/Components/ui/searchable-select';
|
||||
import Pagination from '@/Components/shared/Pagination';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
import { getDateRange } from '@/utils/format';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import GoodsReceiptTable from '@/Components/Inventory/GoodsReceiptTable';
|
||||
|
||||
interface Warehouse {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
warehouse_id?: string;
|
||||
date_start?: string;
|
||||
date_end?: string;
|
||||
per_page?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
receipts: any;
|
||||
filters: Filters;
|
||||
warehouses: Warehouse[];
|
||||
}
|
||||
|
||||
export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Props) {
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [status, setStatus] = useState(filters.status || 'all');
|
||||
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || 'all');
|
||||
const [dateStart, setDateStart] = useState(filters.date_start || '');
|
||||
const [dateEnd, setDateEnd] = useState(filters.date_end || '');
|
||||
const [perPage, setPerPage] = useState(filters.per_page || '10');
|
||||
const [dateRangeType, setDateRangeType] = useState('custom');
|
||||
|
||||
// Advanced Filter Toggle
|
||||
const [showAdvanced, setShowAdvanced] = useState(
|
||||
!!(filters.date_start || filters.date_end)
|
||||
);
|
||||
|
||||
// Sync filters from props
|
||||
useEffect(() => {
|
||||
setSearch(filters.search || '');
|
||||
setStatus(filters.status || 'all');
|
||||
setWarehouseId(filters.warehouse_id || 'all');
|
||||
setDateStart(filters.date_start || '');
|
||||
setDateEnd(filters.date_end || '');
|
||||
setPerPage(filters.per_page || '10');
|
||||
}, [filters]);
|
||||
|
||||
const handleFilter = () => {
|
||||
router.get(route('goods-receipts.index'), {
|
||||
search,
|
||||
status: status !== 'all' ? status : undefined,
|
||||
warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
|
||||
date_start: dateStart || undefined,
|
||||
date_end: dateEnd || undefined,
|
||||
per_page: perPage,
|
||||
}, { preserveState: true, replace: true });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearch('');
|
||||
setStatus('all');
|
||||
setWarehouseId('all');
|
||||
setDateStart('');
|
||||
setDateEnd('');
|
||||
setDateRangeType('custom');
|
||||
setPerPage('10');
|
||||
router.get(route('goods-receipts.index'), {}, { preserveState: false });
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (type: string) => {
|
||||
setDateRangeType(type);
|
||||
if (type === 'custom') return;
|
||||
|
||||
const { start, end } = getDateRange(type);
|
||||
setDateStart(start);
|
||||
setDateEnd(end);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(route('goods-receipts.index'), {
|
||||
search,
|
||||
status: status !== 'all' ? status : undefined,
|
||||
warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
|
||||
date_start: dateStart || undefined,
|
||||
date_end: dateEnd || undefined,
|
||||
per_page: value,
|
||||
}, { preserveState: true, preserveScroll: true, replace: true });
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部狀態', value: 'all' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '處理中', value: 'processing' },
|
||||
];
|
||||
|
||||
const warehouseOptions = [
|
||||
{ label: '全部倉庫', value: 'all' },
|
||||
...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))
|
||||
];
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '供應鏈管理', href: '#' },
|
||||
{ label: '進貨單管理', href: route('goods-receipts.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="進貨單管理" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-primary-main" />
|
||||
進貨單管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
管理所有的進貨單據,包含新增、查詢與查看詳細內容。
|
||||
</p>
|
||||
</div>
|
||||
<Can permission="goods_receipts.create">
|
||||
<Link href={route('goods-receipts.create')}>
|
||||
<Button className="button-filled-primary">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增進貨單
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white p-5 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
{/* Row 1: Search, Status, Warehouse */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋單號..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">狀態</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={warehouseOptions}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
showSearch={warehouses.length > 10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date Filters (Collapsible) */}
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="md:col-span-6 space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-1">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
{ label: "昨日", value: "yesterday" },
|
||||
{ label: "本週", value: "this_week" },
|
||||
{ label: "本月", value: "this_month" },
|
||||
{ label: "上月", value: "last_month" },
|
||||
].map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeChange(opt.value)}
|
||||
className={
|
||||
dateRangeType === opt.value
|
||||
? 'button-filled-primary h-9 px-4 shadow-sm'
|
||||
: 'button-outlined-primary h-9 px-4 bg-white'
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-6">
|
||||
<div className="grid grid-cols-2 gap-4 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => {
|
||||
setDateStart(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">結束日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => {
|
||||
setDateEnd(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white text-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end border-t border-gray-100 pt-5 gap-3 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
收合篩選
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
進階篩選
|
||||
{(dateStart || dateEnd) && (
|
||||
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex items-center gap-2 button-filled-primary h-9 px-6"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<GoodsReceiptTable receipts={receipts.data} />
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<Pagination links={receipts.links} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
221
resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
Normal file
221
resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 查看進貨單詳情頁面
|
||||
*/
|
||||
|
||||
import { ArrowLeft, Package } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import GoodsReceiptStatusBadge from "@/Components/Inventory/GoodsReceiptStatusBadge";
|
||||
import CopyButton from "@/Components/shared/CopyButton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { formatCurrency, formatDate, formatDateTime } from "@/utils/format";
|
||||
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
||||
|
||||
interface GoodsReceiptItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
baseUnit?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
quantity_received: string | number;
|
||||
unit_price: string | number;
|
||||
total_amount: string | number;
|
||||
batch_number?: string;
|
||||
expiry_date?: string;
|
||||
}
|
||||
|
||||
interface GoodsReceipt {
|
||||
id: number;
|
||||
code: string;
|
||||
type: string;
|
||||
received_date: string;
|
||||
status: string;
|
||||
remark?: string;
|
||||
warehouse?: {
|
||||
name: string;
|
||||
};
|
||||
vendor?: {
|
||||
name: string;
|
||||
};
|
||||
items: GoodsReceiptItem[];
|
||||
items_sum_total_amount: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
receipt: GoodsReceipt;
|
||||
}
|
||||
|
||||
export default function ViewGoodsReceiptPage({ receipt }: Props) {
|
||||
const typeMap: Record<string, string> = {
|
||||
standard: "標準採購進貨",
|
||||
miscellaneous: "雜項入庫",
|
||||
other: "其他入庫",
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("goodsReceipts", `詳情 (#${receipt.code})`)}>
|
||||
<Head title={`進貨單詳情 - ${receipt.code}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href="/goods-receipts">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回進貨單列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Package className="h-6 w-6 text-primary-main" />
|
||||
查看進貨單
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">單號:{receipt.code}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<GoodsReceiptStatusBadge status={receipt.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{/* 基本資訊卡片 */}
|
||||
<div className="bg-white rounded-lg border shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6 border-b pb-4">基本資訊</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">進貨單編號</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono font-medium text-gray-900">{receipt.code}</span>
|
||||
<CopyButton text={receipt.code} label="複製單號" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">入庫類型</span>
|
||||
<span className="font-medium text-gray-900">{typeMap[receipt.type] || receipt.type}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">倉庫</span>
|
||||
<span className="font-medium text-gray-900">{receipt.warehouse?.name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">供應商</span>
|
||||
<span className="font-medium text-gray-900">{receipt.vendor?.name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">進貨日期</span>
|
||||
<span className="font-medium text-gray-900">{formatDate(receipt.received_date)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">建立時間</span>
|
||||
<span className="font-medium text-gray-900">{formatDateTime(receipt.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{receipt.remark && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||
<span className="text-sm text-gray-500 block mb-2">備註</span>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-4 rounded-lg leading-relaxed">
|
||||
{receipt.remark}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 品項清單卡片 */}
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h2 className="text-lg font-bold text-gray-900">進貨品項清單</h2>
|
||||
</div>
|
||||
<div className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[80px] text-center">#</TableHead>
|
||||
<TableHead>商品名稱</TableHead>
|
||||
<TableHead className="text-right">進貨數量</TableHead>
|
||||
<TableHead className="text-center">單位</TableHead>
|
||||
<TableHead className="text-right">單價</TableHead>
|
||||
<TableHead className="text-right">小計</TableHead>
|
||||
<TableHead>批號</TableHead>
|
||||
<TableHead>效期</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{receipt.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-gray-500">
|
||||
無品項資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
receipt.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center text-gray-500">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{item.product.name}</span>
|
||||
<span className="text-xs text-gray-500 font-mono">{item.product.code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{Number(item.quantity_received).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.product.baseUnit?.name || "個"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(Number(item.unit_price))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-primary">
|
||||
{formatCurrency(Number(item.total_amount))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">{item.batch_number || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{item.expiry_date ? formatDate(item.expiry_date) : "-"}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 總計 */}
|
||||
<div className="p-6 border-t border-gray-100 flex justify-end">
|
||||
<div className="w-full max-w-xs bg-gray-50/50 px-6 py-4 rounded-xl border border-gray-100 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">總計金額</span>
|
||||
<span className="text-2xl font-black text-primary">
|
||||
{formatCurrency(receipt.items_sum_total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
255
resources/js/Pages/Inventory/Transfer/Index.tsx
Normal file
255
resources/js/Pages/Inventory/Transfer/Index.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
FileText,
|
||||
ArrowLeftRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function Index({ auth, orders, warehouses, filters }) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Create Dialog State
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
|
||||
const [targetWarehouseId, setTargetWarehouseId] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Handle warehouse filter change
|
||||
const handleFilterChange = (value) => {
|
||||
router.get(route('inventory.transfer.index'), { warehouse_id: value }, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!sourceWarehouseId) {
|
||||
toast.error("請選擇來源倉庫");
|
||||
return;
|
||||
}
|
||||
if (!targetWarehouseId) {
|
||||
toast.error("請選擇目的倉庫");
|
||||
return;
|
||||
}
|
||||
if (sourceWarehouseId === targetWarehouseId) {
|
||||
toast.error("來源與目的倉庫不能相同");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
router.post(route('inventory.transfer.store'), {
|
||||
from_warehouse_id: sourceWarehouseId,
|
||||
to_warehouse_id: targetWarehouseId
|
||||
}, {
|
||||
onFinish: () => setCreating(false),
|
||||
onSuccess: () => {
|
||||
setIsCreateOpen(false);
|
||||
setSourceWarehouseId("");
|
||||
setTargetWarehouseId("");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">草稿</Badge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-600">已完成</Badge>;
|
||||
case 'voided':
|
||||
return <Badge variant="destructive">已作廢</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
user={auth.user}
|
||||
header={
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-semibold text-xl text-gray-800 leading-tight flex items-center gap-2">
|
||||
<ArrowLeftRight className="h-5 w-5" />
|
||||
庫存調撥
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title="庫存調撥" />
|
||||
|
||||
<div className="py-12">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-[200px]">
|
||||
<Select
|
||||
value={filters.warehouse_id || "all"}
|
||||
onValueChange={(val) => handleFilterChange(val === "all" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="篩選倉庫..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">所有倉庫</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="搜尋調撥單號..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增調撥單
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>建立新調撥單</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>來源倉庫</Label>
|
||||
<Select onValueChange={setSourceWarehouseId} value={sourceWarehouseId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="選擇來源倉庫" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map(w => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目的倉庫</Label>
|
||||
<Select onValueChange={setTargetWarehouseId} value={targetWarehouseId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="選擇目的倉庫" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.filter(w => String(w.id) !== sourceWarehouseId).map(w => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
建立草稿
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>單號</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>來源倉庫</TableHead>
|
||||
<TableHead>目的倉庫</TableHead>
|
||||
<TableHead>建立日期</TableHead>
|
||||
<TableHead>過帳日期</TableHead>
|
||||
<TableHead>建立人員</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
|
||||
尚無調撥單
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
orders.data.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="font-medium">{order.doc_no}</TableCell>
|
||||
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
||||
<TableCell>{order.from_warehouse_name}</TableCell>
|
||||
<TableCell>{order.to_warehouse_name}</TableCell>
|
||||
<TableCell>{order.created_at}</TableCell>
|
||||
<TableCell>{order.posted_at}</TableCell>
|
||||
<TableCell>{order.created_by}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href={route('inventory.transfer.show', [order.id])}>
|
||||
查看
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination links={orders.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
336
resources/js/Pages/Inventory/Transfer/Show.tsx
Normal file
336
resources/js/Pages/Inventory/Transfer/Show.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, usePage } from "@inertiajs/react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Ban, History, Package } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
|
||||
export default function Show({ auth, order }) {
|
||||
const [items, setItems] = useState(order.items || []);
|
||||
const [remarks, setRemarks] = useState(order.remarks || "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Product Selection
|
||||
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
|
||||
const [availableInventory, setAvailableInventory] = useState([]);
|
||||
const [loadingInventory, setLoadingInventory] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isProductDialogOpen) {
|
||||
loadInventory();
|
||||
}
|
||||
}, [isProductDialogOpen]);
|
||||
|
||||
const loadInventory = async () => {
|
||||
setLoadingInventory(true);
|
||||
try {
|
||||
// Fetch inventory from SOURCE warehouse
|
||||
const response = await axios.get(route('api.warehouses.inventories', order.from_warehouse_id));
|
||||
setAvailableInventory(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load inventory", error);
|
||||
toast.error("無法載入庫存資料");
|
||||
} finally {
|
||||
setLoadingInventory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddItem = (inventoryItem) => {
|
||||
// Check if already added
|
||||
const exists = items.find(i =>
|
||||
i.product_id === inventoryItem.product_id &&
|
||||
i.batch_number === inventoryItem.batch_number
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
toast.error("該商品與批號已在列表中");
|
||||
return;
|
||||
}
|
||||
|
||||
setItems([...items, {
|
||||
product_id: inventoryItem.product_id,
|
||||
product_name: inventoryItem.product_name,
|
||||
product_code: inventoryItem.product_code,
|
||||
batch_number: inventoryItem.batch_number,
|
||||
unit: inventoryItem.unit_name,
|
||||
quantity: 1, // Default 1
|
||||
max_quantity: inventoryItem.quantity, // Max available
|
||||
notes: "",
|
||||
}]);
|
||||
setIsProductDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdateItem = (index, field, value) => {
|
||||
const newItems = [...items];
|
||||
newItems[index][field] = value;
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index) => {
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await router.put(route('inventory.transfer.update', [order.id]), {
|
||||
items: items,
|
||||
remarks: remarks,
|
||||
}, {
|
||||
onSuccess: () => toast.success("儲存成功"),
|
||||
onError: () => toast.error("儲存失敗,請檢查輸入"),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePost = () => {
|
||||
if (!confirm("確定要過帳嗎?過帳後庫存將立即轉移且無法修改。")) return;
|
||||
router.put(route('inventory.transfer.update', [order.id]), {
|
||||
action: 'post'
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm("確定要刪除此草稿嗎?")) return;
|
||||
router.delete(route('inventory.transfer.destroy', [order.id]));
|
||||
};
|
||||
|
||||
const isReadOnly = order.status !== 'draft';
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
user={auth.user}
|
||||
header={
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-semibold text-xl text-gray-800 leading-tight flex items-center gap-2">
|
||||
<ArrowLeft className="h-5 w-5 cursor-pointer" onClick={() => router.visit(route('inventory.transfer.index'))} />
|
||||
調撥單詳情 ({order.doc_no})
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
刪除
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
儲存草稿
|
||||
</Button>
|
||||
<Button onClick={handlePost} disabled={items.length === 0}>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
確認過帳
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ label: '首頁', href: '/' },
|
||||
{ label: '庫存調撥', href: route('inventory.transfer.index') },
|
||||
{ label: order.doc_no, href: route('inventory.transfer.show', [order.id]) },
|
||||
]}
|
||||
>
|
||||
<Head title={`調撥單 ${order.doc_no}`} />
|
||||
|
||||
<div className="py-12">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
{/* Header Info */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100 grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<Label className="text-gray-500">來源倉庫</Label>
|
||||
<div className="font-medium text-lg">{order.from_warehouse_name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-500">目的倉庫</Label>
|
||||
<div className="font-medium text-lg">{order.to_warehouse_name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-500">狀態</Label>
|
||||
<div className="mt-1">
|
||||
{order.status === 'draft' && <Badge variant="secondary">草稿</Badge>}
|
||||
{order.status === 'completed' && <Badge className="bg-green-600">已完成</Badge>}
|
||||
{order.status === 'voided' && <Badge variant="destructive">已作廢</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-500">備註</Label>
|
||||
{isReadOnly ? (
|
||||
<div className="mt-1 text-gray-700">{order.remarks || '-'}</div>
|
||||
) : (
|
||||
<Input
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="填寫備註..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">調撥明細</h3>
|
||||
{!isReadOnly && (
|
||||
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
加入商品
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>選擇來源庫存 ({order.from_warehouse_name})</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
{loadingInventory ? (
|
||||
<div className="text-center py-4">載入中...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>商品代號</TableHead>
|
||||
<TableHead>品名</TableHead>
|
||||
<TableHead>批號</TableHead>
|
||||
<TableHead className="text-right">現有庫存</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableInventory.map((inv) => (
|
||||
<TableRow key={`${inv.product_id}-${inv.batch_number}`}>
|
||||
<TableCell>{inv.product_code}</TableCell>
|
||||
<TableCell>{inv.product_name}</TableCell>
|
||||
<TableCell>{inv.batch_number || '-'}</TableCell>
|
||||
<TableCell className="text-right">{inv.quantity} {inv.unit_name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" onClick={() => handleAddItem(inv)}>
|
||||
選取
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">#</TableHead>
|
||||
<TableHead>商品</TableHead>
|
||||
<TableHead>批號</TableHead>
|
||||
<TableHead className="w-[150px]">調撥數量</TableHead>
|
||||
<TableHead>單位</TableHead>
|
||||
<TableHead>備註</TableHead>
|
||||
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center h-24 text-gray-500">
|
||||
尚未加入商品
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.product_name}</div>
|
||||
<div className="text-xs text-gray-500">{item.product_code}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.batch_number || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
item.quantity
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={item.quantity}
|
||||
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
|
||||
/>
|
||||
{item.max_quantity && (
|
||||
<span className="text-xs text-gray-500">上限: {item.max_quantity}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.unit}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
item.notes
|
||||
) : (
|
||||
<Input
|
||||
value={item.notes}
|
||||
onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)}
|
||||
placeholder="備註"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleRemoveItem(index)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Factory, Search, RotateCcw, Eye, Pencil } from 'lucide-react';
|
||||
import { Plus, Factory, Search, RotateCcw, Eye, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
@@ -254,6 +254,7 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
</Link>
|
||||
</Can>
|
||||
)}
|
||||
<Can permission="production_orders.view">
|
||||
<Link href={route('production-orders.show', order.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -264,6 +265,22 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
<Can permission="production_orders.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => {
|
||||
if (confirm('確定要刪除此生產工單嗎?')) {
|
||||
router.delete(route('production-orders.destroy', order.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -296,6 +313,6 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</AuthenticatedLayout >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,18 @@ import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
|
||||
interface Recipe {
|
||||
id: number;
|
||||
@@ -98,12 +110,14 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Can permission="recipes.create">
|
||||
<Link href={route('recipes.create')}>
|
||||
<Button className="gap-2 button-filled-primary">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增配方
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,6 +221,7 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Can permission="recipes.edit">
|
||||
<Link href={route('recipes.edit', recipe.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -217,15 +232,39 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
|
||||
<Can permission="recipes.delete">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(recipe.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除配方「{recipe.name}」嗎?此操作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(recipe.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -146,7 +146,8 @@ export default function CreatePurchaseOrder({
|
||||
|
||||
if (order) {
|
||||
router.put(`/purchase-orders/${order.id}`, data, {
|
||||
onSuccess: () => toast.success("採購單已更新"),
|
||||
|
||||
onSuccess: () => { },//toast.success("採購單已更新"),
|
||||
onError: (errors) => {
|
||||
// 顯示更詳細的錯誤訊息
|
||||
if (errors.items) {
|
||||
@@ -161,7 +162,8 @@ export default function CreatePurchaseOrder({
|
||||
});
|
||||
} else {
|
||||
router.post("/purchase-orders", data, {
|
||||
onSuccess: () => toast.success("採購單已成功建立"),
|
||||
|
||||
onSuccess: () => { },//toast.success("採購單已成功建立"),
|
||||
onError: (errors) => {
|
||||
if (errors.items) {
|
||||
toast.error("商品資料有誤,請檢查數量和單價是否正確填寫");
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { STATUS_OPTIONS } from "@/constants/purchase-order";
|
||||
|
||||
interface Props {
|
||||
orders: {
|
||||
@@ -176,13 +177,11 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部狀態</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="pending">待審核</SelectItem>
|
||||
<SelectItem value="processing">處理中</SelectItem>
|
||||
<SelectItem value="shipping">運送中</SelectItem>
|
||||
<SelectItem value="confirming">待確認</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="cancelled">已取消</SelectItem>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -147,19 +147,24 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
items={order.items}
|
||||
isReadOnly={true}
|
||||
/>
|
||||
<div className="mt-4 flex flex-col items-end gap-2 border-t pt-4">
|
||||
<div className="flex items-center gap-8 text-gray-600">
|
||||
<span className="font-medium">小計</span>
|
||||
<span>{formatCurrency(order.totalAmount)}</span>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">小計</span>
|
||||
<span className="text-lg font-bold text-gray-700">{formatCurrency(order.totalAmount)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-gray-600">
|
||||
<span className="font-medium">稅額</span>
|
||||
<span>{formatCurrency(order.tax_amount || 0)}</span>
|
||||
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">稅額</span>
|
||||
<span className="text-lg font-bold text-gray-700">{formatCurrency(order.taxAmount || 0)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 pt-2 mt-2 border-t border-gray-100">
|
||||
<span className="font-bold text-lg">總計</span>
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{formatCurrency(order.grand_total || (order.totalAmount + (order.tax_amount || 0)))}
|
||||
|
||||
<div className="h-px bg-primary/10 w-full my-1"></div>
|
||||
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">總計 (含稅)</span>
|
||||
<span className="text-2xl font-black text-primary">
|
||||
{formatCurrency(order.grandTotal || (order.totalAmount + (order.taxAmount || 0)))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,6 +173,7 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => `${title} - ${appName}`,
|
||||
title: (title) => `${title} - ${window.appName || appName}`,
|
||||
resolve: (name) => resolvePageComponent(`./Pages/${name}.tsx`, import.meta.glob('./Pages/**/*.tsx')),
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el);
|
||||
|
||||
@@ -10,12 +10,12 @@ export const STATUS_CONFIG: Record<
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
|
||||
> = {
|
||||
draft: { label: "草稿", variant: "outline" },
|
||||
pending: { label: "待審核", variant: "outline" },
|
||||
processing: { label: "處理中", variant: "outline" },
|
||||
shipping: { label: "運送中", variant: "outline" },
|
||||
confirming: { label: "待確認", variant: "outline" },
|
||||
completed: { label: "已完成", variant: "outline" },
|
||||
cancelled: { label: "已取消", variant: "outline" },
|
||||
pending: { label: "簽核中", variant: "outline" },
|
||||
approved: { label: "已核准", variant: "default" },
|
||||
partial: { label: "部分收貨", variant: "secondary" },
|
||||
completed: { label: "全數收貨", variant: "outline" },
|
||||
closed: { label: "已結案", variant: "outline" },
|
||||
cancelled: { label: "已作廢", variant: "destructive" },
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG).map(([value, config]) => ({
|
||||
|
||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@@ -31,6 +31,7 @@ export interface PageProps {
|
||||
declare global {
|
||||
interface Window {
|
||||
axios: AxiosInstance;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
var route: typeof routeFn;
|
||||
|
||||
27
resources/js/types/goods-receipt.ts
Normal file
27
resources/js/types/goods-receipt.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface GoodsReceipt {
|
||||
id: number;
|
||||
code: string;
|
||||
warehouse_id: number;
|
||||
warehouse?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
vendor_id?: number;
|
||||
vendor?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
purchase_order_id?: number;
|
||||
purchase_order?: {
|
||||
code: string; // If loaded
|
||||
};
|
||||
received_date: string;
|
||||
status: 'completed' | 'processing' | 'cancelled';
|
||||
remarks?: string;
|
||||
items_sum_total_amount?: number; // Calculated field
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
export type PurchaseOrderStatus =
|
||||
| "draft" // 草稿
|
||||
| "pending" // 待審核
|
||||
| "processing" // 處理中
|
||||
| "shipping" // 運送中
|
||||
| "confirming" // 待確認
|
||||
| "completed" // 已完成
|
||||
| "cancelled"; // 已取消
|
||||
| "pending" // 簽核中
|
||||
| "approved" // 已核准
|
||||
| "partial" // 部分收貨
|
||||
| "completed" // 全數收貨
|
||||
| "closed" // 已結案
|
||||
| "cancelled"; // 已作廢
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface Warehouse {
|
||||
total_quantity?: number;
|
||||
low_stock_count?: number;
|
||||
type?: WarehouseType;
|
||||
is_sellable?: boolean;
|
||||
license_plate?: string; // 車牌號碼 (移動倉)
|
||||
driver_name?: string; // 司機姓名 (移動倉)
|
||||
book_stock?: number;
|
||||
|
||||
@@ -15,12 +15,12 @@ export const BREADCRUMB_MAP: Record<string, BreadcrumbItemType[]> = {
|
||||
{ label: "倉庫管理", href: "/warehouses", isPage: true }
|
||||
],
|
||||
vendors: [
|
||||
{ label: "廠商管理" },
|
||||
{ label: "供應鏈管理", href: '#' },
|
||||
{ label: "廠商資料管理", href: "/vendors", isPage: true }
|
||||
],
|
||||
purchaseOrders: [
|
||||
{ label: "採購管理" },
|
||||
{ label: "管理採購單", href: "/purchase-orders", isPage: true }
|
||||
{ label: "供應鏈管理", href: '#' },
|
||||
{ label: "採購單管理", href: "/purchase-orders", isPage: true }
|
||||
],
|
||||
productionOrders: [
|
||||
{ label: "生產管理" },
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
@viteReactRefresh
|
||||
@vite(['resources/js/app.tsx', "resources/js/Pages/{$page['component']}.tsx"])
|
||||
@inertiaHead
|
||||
<script>
|
||||
window.appName = "{{ $appName ?? config('app.name') }}";
|
||||
</script>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
@inertia
|
||||
|
||||
0
storage/app/public/defaults/.gitkeep
Normal file
0
storage/app/public/defaults/.gitkeep
Normal file
BIN
storage/app/public/defaults/login_bg.jpg
Normal file
BIN
storage/app/public/defaults/login_bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
storage/app/public/defaults/logo.png
Normal file
BIN
storage/app/public/defaults/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 588 KiB |
Reference in New Issue
Block a user