From 8e0252e8fc142c2ce2d6072a1c4f3d1652abfbb0 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Fri, 6 Mar 2026 13:21:14 +0800 Subject: [PATCH 01/13] =?UTF-8?q?[FEAT]=20=E5=AF=A6=E4=BD=9C=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E4=BA=8B=E6=A5=AD=E8=B2=BB=E9=99=84=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E5=82=B3=E7=AE=A1=E7=90=86=E8=88=87=E6=9B=B4=E6=96=B0=20UI=20?= =?UTF-8?q?=E5=8D=94=E4=BD=9C=E8=A6=8F=E7=AF=84=E9=98=B2=E5=91=86=E6=A9=9F?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/framework.md | 4 + .../Controllers/UtilityFeeController.php | 76 +++++ app/Modules/Finance/Models/UtilityFee.php | 9 +- .../Finance/Models/UtilityFeeAttachment.php | 36 +++ app/Modules/Finance/Routes/web.php | 5 + .../Finance/Services/FinanceService.php | 2 +- ...7_create_utility_fee_attachments_table.php | 32 ++ .../UtilityFee/AttachmentDialog.tsx | 304 ++++++++++++++++++ .../UtilityFee/UtilityFeeDialog.tsx | 14 +- resources/js/Pages/UtilityFee/Index.tsx | 35 ++ 10 files changed, 511 insertions(+), 6 deletions(-) create mode 100644 app/Modules/Finance/Models/UtilityFeeAttachment.php create mode 100644 database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php create mode 100644 resources/js/Components/UtilityFee/AttachmentDialog.tsx diff --git a/.agents/rules/framework.md b/.agents/rules/framework.md index 91cfb80..e6ff1c7 100644 --- a/.agents/rules/framework.md +++ b/.agents/rules/framework.md @@ -62,6 +62,10 @@ trigger: always_on * 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。 * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 + * **🔴 核心要求:UI 規範與彈性設計 (重要)**: + * 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。 + * **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。 + * **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。 ## 8. 多租戶開發規範 (Multi-tenancy Standards) 本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則: diff --git a/app/Modules/Finance/Controllers/UtilityFeeController.php b/app/Modules/Finance/Controllers/UtilityFeeController.php index 9cc7738..b8c00fe 100644 --- a/app/Modules/Finance/Controllers/UtilityFeeController.php +++ b/app/Modules/Finance/Controllers/UtilityFeeController.php @@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers; use App\Http\Controllers\Controller; use App\Modules\Finance\Models\UtilityFee; +use App\Modules\Finance\Models\UtilityFeeAttachment; use App\Modules\Finance\Contracts\FinanceServiceInterface; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; class UtilityFeeController extends Controller @@ -103,8 +105,82 @@ class UtilityFeeController extends Controller ->event('deleted') ->log('deleted'); + // 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理) + foreach ($utility_fee->attachments as $attachment) { + Storage::disk('public')->delete($attachment->file_path); + } + $utility_fee->delete(); return redirect()->back(); } + + /** + * 獲取附件列表 + */ + public function attachments(UtilityFee $utility_fee) + { + return response()->json([ + 'attachments' => $utility_fee->attachments()->orderBy('created_at', 'desc')->get() + ]); + } + + /** + * 上傳附件 + */ + public function uploadAttachment(Request $request, UtilityFee $utility_fee) + { + $request->validate([ + 'file' => 'required|file|mimes:jpeg,jpg,png,webp,pdf|max:2048', // 2MB + ]); + + // 檢查數量限制 (最多 3 張) + if ($utility_fee->attachments()->count() >= 3) { + return response()->json(['message' => '附件數量已達上限 (最多 3 個)'], 422); + } + + $file = $request->file('file'); + $path = $file->store("utility-fee-attachments/{$utility_fee->id}", 'public'); + + $attachment = $utility_fee->attachments()->create([ + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + ]); + + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('attachment_uploaded') + ->log("uploaded attachment: {$attachment->original_name}"); + + return response()->json([ + 'message' => '上傳成功', + 'attachment' => $attachment + ]); + } + + /** + * 刪除附件 + */ + public function deleteAttachment(UtilityFee $utility_fee, UtilityFeeAttachment $attachment) + { + // 確保附件屬於該費用 + if ($attachment->utility_fee_id !== $utility_fee->id) { + abort(403); + } + + Storage::disk('public')->delete($attachment->file_path); + + $attachment->delete(); + + activity() + ->performedOn($utility_fee) + ->causedBy(auth()->user()) + ->event('attachment_deleted') + ->log("deleted attachment: {$attachment->original_name}"); + + return response()->json(['message' => '刪除成功']); + } } diff --git a/app/Modules/Finance/Models/UtilityFee.php b/app/Modules/Finance/Models/UtilityFee.php index aba6528..d362b50 100644 --- a/app/Modules/Finance/Models/UtilityFee.php +++ b/app/Modules/Finance/Models/UtilityFee.php @@ -7,9 +7,16 @@ use Illuminate\Database\Eloquent\Model; class UtilityFee extends Model { - /** @use HasFactory<\Database\Factories\UtilityFeeFactory> */ use HasFactory; + /** + * 此公共事業費的附件 + */ + public function attachments() + { + return $this->hasMany(UtilityFeeAttachment::class); + } + // 狀態常數 const STATUS_PENDING = 'pending'; const STATUS_PAID = 'paid'; diff --git a/app/Modules/Finance/Models/UtilityFeeAttachment.php b/app/Modules/Finance/Models/UtilityFeeAttachment.php new file mode 100644 index 0000000..f9b0575 --- /dev/null +++ b/app/Modules/Finance/Models/UtilityFeeAttachment.php @@ -0,0 +1,36 @@ +belongsTo(UtilityFee::class); + } + + /** + * 獲取附件的全路徑 URL + */ + public function getUrlAttribute() + { + return tenant_asset($this->file_path); + } +} diff --git a/app/Modules/Finance/Routes/web.php b/app/Modules/Finance/Routes/web.php index 1ec273d..01aae1f 100644 --- a/app/Modules/Finance/Routes/web.php +++ b/app/Modules/Finance/Routes/web.php @@ -30,6 +30,11 @@ Route::middleware('auth')->group(function () { }); Route::middleware('permission:utility_fees.edit')->group(function () { Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update'); + + // 附件管理 (Ajax) + Route::get('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'attachments'])->name('utility-fees.attachments'); + Route::post('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'uploadAttachment'])->name('utility-fees.upload-attachment'); + Route::delete('/utility-fees/{utility_fee}/attachments/{attachment}', [UtilityFeeController::class, 'deleteAttachment'])->name('utility-fees.delete-attachment'); }); Route::middleware('permission:utility_fees.delete')->group(function () { Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy'); diff --git a/app/Modules/Finance/Services/FinanceService.php b/app/Modules/Finance/Services/FinanceService.php index 45ab42d..36dffed 100644 --- a/app/Modules/Finance/Services/FinanceService.php +++ b/app/Modules/Finance/Services/FinanceService.php @@ -67,7 +67,7 @@ class FinanceService implements FinanceServiceInterface public function getUtilityFees(array $filters) { - $query = UtilityFee::query(); + $query = UtilityFee::withCount('attachments'); if (!empty($filters['search'])) { $search = $filters['search']; diff --git a/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php b/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php new file mode 100644 index 0000000..a36bb2f --- /dev/null +++ b/database/migrations/tenant/2026_03_06_113117_create_utility_fee_attachments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('utility_fee_id')->constrained('utility_fees')->cascadeOnDelete(); + $table->string('file_path'); // 儲存路徑 + $table->string('original_name'); // 原始檔名 + $table->string('mime_type'); // MIME 類型 + $table->unsignedBigInteger('size'); // 檔案大小 (bytes) + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('utility_fee_attachments'); + } +}; diff --git a/resources/js/Components/UtilityFee/AttachmentDialog.tsx b/resources/js/Components/UtilityFee/AttachmentDialog.tsx new file mode 100644 index 0000000..469ba24 --- /dev/null +++ b/resources/js/Components/UtilityFee/AttachmentDialog.tsx @@ -0,0 +1,304 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogDescription, + DialogTitle, +} from "@/Components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; +import { Button } from "@/Components/ui/button"; +import { + FileText, + Loader2, + Trash2, + Eye, + Upload +} from "lucide-react"; +import { UtilityFee } from "./UtilityFeeDialog"; +import { toast } from "sonner"; +import axios from "axios"; + +interface Attachment { + id: number; + url: string; + original_name: string; + mime_type: string; + size: number; +} + + + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + fee: UtilityFee | null; + onAttachmentsChange?: () => void; +} + +export default function AttachmentDialog({ + open, + onOpenChange, + fee, + onAttachmentsChange, +}: Props) { + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + useEffect(() => { + if (open && fee) { + fetchAttachments(); + } else { + setAttachments([]); + } + }, [open, fee]); + + const fetchAttachments = async () => { + if (!fee) return; + setLoading(true); + try { + const response = await axios.get(route("utility-fees.attachments", fee.id)); + setAttachments(response.data.attachments || []); + } catch (error) { + toast.error("取得附件失敗"); + } finally { + setLoading(false); + } + }; + + const handleUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0 || !fee) return; + + const file = files[0]; + + // 驗證檔案大小 (2MB) + if (file.size > 2 * 1024 * 1024) { + toast.error("檔案大小不能超過 2MB"); + return; + } + + // 驗證檔案類型 + const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"]; + if (!allowedTypes.includes(file.type)) { + toast.error("僅支援 JPG, PNG, WebP 圖片及 PDF 文件"); + return; + } + + // 驗證數量限制 (3張) + if (attachments.length >= 3) { + toast.error("最多僅可上傳 3 個附件"); + return; + } + + const formData = new FormData(); + formData.append("file", file); + + setUploading(true); + try { + await axios.post(route("utility-fees.upload-attachment", fee.id), formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + toast.success("上傳成功"); + fetchAttachments(); + onAttachmentsChange?.(); + } catch (error: any) { + const errorMsg = error.response?.data?.message || Object.values(error.response?.data?.errors || {})[0] || "上傳失敗"; + toast.error(errorMsg as string); + } finally { + setUploading(false); + // 清空 input 讓同一個檔案可以重複觸發 onChange + e.target.value = ""; + } + }; + + const confirmDelete = (id: number) => { + setDeleteId(id); + setIsDeleteDialogOpen(true); + }; + + const handleDelete = async () => { + if (!deleteId || !fee) return; + + setIsDeleting(true); + try { + await axios.delete(route("utility-fees.delete-attachment", [fee.id, deleteId])); + toast.success("附件已刪除"); + setAttachments(attachments.filter((a) => a.id !== deleteId)); + onAttachmentsChange?.(); + } catch (error) { + toast.error("刪除失敗"); + } finally { + setIsDeleting(false); + setDeleteId(null); + setIsDeleteDialogOpen(false); + } + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + return ( + <> + + + + + + 附件管理 + + + 管理 {fee?.billing_month} {fee?.category_name} 的支援文件(對帳單、收據等)。 + 最多可上傳 3 個檔案,單一檔案上限 2MB。 + + + +
+ {/* 上傳區塊 */} +
+
+ {attachments.length < 3 + ? `還可以上傳 ${3 - attachments.length} 個附件` + : "已達到上傳數量上限"} +
+ = 3} + accept=".jpg,.jpeg,.png,.webp,.pdf" + /> + +
+ + {/* 附件列表 */} +
+ {loading ? ( +
+ +

載入中...

+
+ ) : attachments.length === 0 ? ( +
+

目前尚無附件

+
+ ) : ( +
+ {attachments.map((file) => ( +
+
+
+ {file.mime_type.startsWith("image/") ? ( + {file.original_name} { + (e.target as HTMLImageElement).src = ""; + (e.target as HTMLImageElement).className = "hidden"; + (e.target as HTMLImageElement).parentElement?.classList.add("bg-slate-200"); + }} + /> + ) : ( + + )} +
+
+

+ {file.original_name} +

+

+ {formatSize(file.size)} +

+
+
+ +
+ + +
+
+ ))} +
+ )} +
+
+
+
+ + + + + 確認刪除附件? + + 這將永久刪除此附件,此操作無法撤銷。 + + + + 取消 + + 確認刪除 + + + + + + ); +} diff --git a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx index 98e1951..83ee3ee 100644 --- a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx +++ b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx @@ -20,13 +20,19 @@ import { validateInvoiceNumber } from "@/utils/validation"; export interface UtilityFee { id: number; - transaction_date: string | null; - due_date: string; - category: string; + billing_month: string; + category_id: number; + category?: string; // 相容於舊版或特定視圖 + category_name?: string; amount: number | string; - payment_status: 'pending' | 'paid' | 'overdue'; + status: string; + payment_status?: 'pending' | 'paid' | 'overdue'; // 相容於舊版 + due_date: string; + payment_date?: string; + transaction_date?: string; // 相容於舊版 invoice_number?: string; description?: string; + attachments_count?: number; created_at: string; updated_at: string; } diff --git a/resources/js/Pages/UtilityFee/Index.tsx b/resources/js/Pages/UtilityFee/Index.tsx index 8e54c00..fcd9c97 100644 --- a/resources/js/Pages/UtilityFee/Index.tsx +++ b/resources/js/Pages/UtilityFee/Index.tsx @@ -30,6 +30,7 @@ import { import { Badge } from "@/Components/ui/badge"; import { toast } from "sonner"; import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog"; +import AttachmentDialog from "@/Components/UtilityFee/AttachmentDialog"; import { AlertDialog, AlertDialogAction, @@ -77,8 +78,19 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }: const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isAttachmentDialogOpen, setIsAttachmentDialogOpen] = useState(false); const [editingFee, setEditingFee] = useState(null); const [deletingFeeId, setDeletingFeeId] = useState(null); + const [attachmentFee, setAttachmentFee] = useState(null); + + const openAttachmentDialog = (fee: UtilityFee) => { + setAttachmentFee(fee); + setIsAttachmentDialogOpen(true); + }; + + const handleAttachmentsChange = () => { + router.reload({ only: ['fees'] }); + }; @@ -447,6 +459,22 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
+ + +
- +
- 採購支出 - $ {Number(summary.purchase_total).toLocaleString()} + 應付帳款 + $ {Number(summary.payable_total).toLocaleString()}
@@ -305,13 +312,16 @@ export default function AccountingReport({ records, summary, filters }: PageProp 來源 類別 項目詳細 - 金額 + 付款方式 / 備註 + 狀態 + 稅額 + 總金額 {records.data.length === 0 ? ( - +

此日期區間內無支出紀錄

@@ -333,7 +343,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp @@ -348,11 +358,47 @@ export default function AccountingReport({ records, summary, filters }: PageProp
{record.item} - {record.invoice_number && ( - 發票:{record.invoice_number} + {(record.invoice_number || record.invoice_date) && ( + + 發票:{record.invoice_number || '-'} + {record.invoice_date && ` (${record.invoice_date})`} + + )} + {record.remarks && ( + + 備註:{record.remarks} + )}
+ +
+ {record.payment_method || '-'} + {record.payment_note && ( + + {record.payment_note} + + )} +
+
+ + {record.status === 'paid' ? ( + 已付款 + ) : record.status === 'pending' ? ( + 待付款 + ) : record.status === 'overdue' ? ( + 已逾期 + ) : record.status === 'draft' ? ( + 草稿 + ) : record.status === 'approved' ? ( + 已核准 + ) : ( + {record.status || '-'} + )} + + + {record.tax_amount ? `$ ${Number(record.tax_amount).toLocaleString()}` : '-'} + $ {Number(record.amount).toLocaleString()} From 02e5f5d4ead86c89da712457132ba78481a6c713 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Fri, 6 Mar 2026 14:54:54 +0800 Subject: [PATCH 04/13] =?UTF-8?q?[DOCS]=20=E9=87=8D=E6=A7=8B=20Git=20?= =?UTF-8?q?=E7=99=BC=E5=B8=83=E8=A6=8F=E7=AF=84=EF=BC=9A=E5=B0=87=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=80=A7=E6=AA=A2=E6=9F=A5=E8=A6=8F=E5=89=87=E6=94=B6?= =?UTF-8?q?=E6=94=8F=E8=87=B3=20SKILL.md=20=E4=B8=A6=E8=88=87=20now-push?= =?UTF-8?q?=20=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/git-workflows/SKILL.md | 7 +++--- .agents/workflows/now-push.md | 32 ++++++++++----------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/.agents/skills/git-workflows/SKILL.md b/.agents/skills/git-workflows/SKILL.md index 388a709..97b9c7d 100644 --- a/.agents/skills/git-workflows/SKILL.md +++ b/.agents/skills/git-workflows/SKILL.md @@ -18,11 +18,12 @@ description: 規範開發過程中的 Git 分支架構、合併限制、環境 ## 2. 發布時段與約束 (Release Window) ### Main 分支發布限制 (Mandatory) -1. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。 -2. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`: +1. **強制規範**:若執行推送/合併指令時未明確包含目標分支,**嚴禁** 自行預設或推論為 `main`。我必須先詢問使用者:「請問要推送到哪一個目標分支?(dev / demo / main)」。 +2. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。 +3. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`: - AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。 - 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。 -3. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。 +4. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。 ## 3. 開發與修復流程 (SOP) diff --git a/.agents/workflows/now-push.md b/.agents/workflows/now-push.md index 3fe9709..3b36c3f 100644 --- a/.agents/workflows/now-push.md +++ b/.agents/workflows/now-push.md @@ -8,28 +8,20 @@ description: 將目前的變更提交並推送至指定的遠端分支 (遵守 ## 執行步驟 -1. **檢查變更內容** - 執行 `git status` 與 `git diff` 檢查目前的工作目錄,確保提交內容正確。 +1. **讀取規範 (Mandatory)** + 在執行任何 Git 操作前,**必須** 先讀取 Git 分支管理與開發規範: + `view_file` -> [Git SKILL.md](file:///home/mama/projects/star-erp/.agents/skills/git-workflows/SKILL.md) -2. **撰寫規格化提交訊息 (Commit Message)** - - 訊息一律使用 **繁體中文 (台灣用語)**。 - - 必須使用以下前綴之一: - - `[FIX]`:修復 Bug。 - - `[FEAT]`:新增功能。 - - `[DOCS]`:文件更新。 - - `[STYLE]`:UI/樣式/格式調整。 - - `[REFACTOR]`:程式碼重構。 - - 描述應具體且真實反映修改內容。 +2. **檢查與準備** + - 執行 `git status` 檢查目前工作目錄。 + - 根據 **SKILL.md** 的規範撰寫繁體中文提交訊息。 -3. **目標分支安全檢查 (Release Window & Source Check)** - - 若使用者指定的目標分支包含 **`main`**: - - **來源檢查**:根據規範,上線 `main` 前必須先確保程式碼已在 `demo` 分支驗證完畢。我會優先檢查 `demo` 與 `main` 的差異,並提醒使用者應從 `demo` 合併。 - - **檢查目前時間**:標準發布時段為 **週一至週四 12:00 (中午) 之前**。 - - 若在非標準時段(週五、週末、下班時間),**必須** 先攔截並主動提醒風險,取得使用者明確書面同意(例如:「我確定現在要上線」)後方才執行推送。 +3. **目標分支安全檢查** + - 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。 + - 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`。 4. **執行推送 (Push)** - - 依據指令帶入的分支名稱執行推送。 - - 範例:`git push origin [目前分支]:[目標分支]`。 + - 通過安全檢查後,執行:`git push origin [目前分支]:[目標分支]`。 -5. **同步關聯分支** - - 若為 `main` 的 Hotfix,修復後應評估是否同步回 `demo` 或 `dev` 分支。 \ No newline at end of file +5. **後續同步** + - 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」評估是否需要同步回 `demo` 或 `dev` 分支。 \ No newline at end of file From e11193c2a7ce6d9a41dfbcebed9e2226c750ecc4 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Fri, 6 Mar 2026 15:38:27 +0800 Subject: [PATCH 05/13] =?UTF-8?q?[FEAT]=20=E5=B0=8E=E5=85=A5=20Playwright?= =?UTF-8?q?=20E2E=20=E6=B8=AC=E8=A9=A6=E7=92=B0=E5=A2=83=E8=88=87=E7=99=BB?= =?UTF-8?q?=E5=85=A5=E5=8A=9F=E8=83=BD=E6=B8=AC=E8=A9=A6=E8=85=B3=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/skill-trigger.md | 2 + .agents/skills/e2e-testing/SKILL.md | 266 ++++++++++++++++++++++++++++ .gitignore | 9 + e2e/helpers/auth.ts | 14 ++ e2e/login.spec.ts | 72 ++++++++ package-lock.json | 83 ++++++++- package.json | 115 ++++++------ playwright.config.ts | 47 +++++ 8 files changed, 548 insertions(+), 60 deletions(-) create mode 100644 .agents/skills/e2e-testing/SKILL.md create mode 100644 e2e/helpers/auth.ts create mode 100644 e2e/login.spec.ts create mode 100644 playwright.config.ts diff --git a/.agents/rules/skill-trigger.md b/.agents/rules/skill-trigger.md index a78b82c..b06eddc 100644 --- a/.agents/rules/skill-trigger.md +++ b/.agents/rules/skill-trigger.md @@ -19,6 +19,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 | 跨模組、Service Interface、`Contracts`、模組間通訊、`ServiceProvider` 綁定、禁止跨模組引用 | **跨模組調用與通訊規範** | `.agents/skills/cross-module-communication/SKILL.md` | | 按鈕樣式、表格規範、圖標、分頁、Badge、Toast、表單、UI 統一、頁面佈局、`button-filled-*`、`button-outlined-*`、`lucide-react`、色彩系統 | **客戶端後台 UI 統一規範** | `.agents/skills/ui-consistency/SKILL.md` | | Git 分支、commit、push、合併、部署、`feature/`、`hotfix/`、`develop`、`main` | **Git 分支管理與開發規範** | `.agents/skills/git-workflows/SKILL.md` | +| E2E、端到端測試、Playwright、`spec.ts`、功能驗證、自動化測試、回歸測試 | **E2E 端到端測試規範** | `.agents/skills/e2e-testing/SKILL.md` | --- @@ -31,6 +32,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 1. **permission-management** — 設定權限 2. **ui-consistency** — 遵循 UI 規範 3. **activity-logging** — 若涉及 Model CRUD,需加上操作紀錄 +4. **e2e-testing** — 確認是否需要新增對應的 E2E 測試 ### 🔴 新增或修改 Model 時 必須讀取: diff --git a/.agents/skills/e2e-testing/SKILL.md b/.agents/skills/e2e-testing/SKILL.md new file mode 100644 index 0000000..c4faa2c --- /dev/null +++ b/.agents/skills/e2e-testing/SKILL.md @@ -0,0 +1,266 @@ +--- +name: E2E 端到端測試規範 (E2E Testing with Playwright) +description: 規範 Playwright 端到端測試的撰寫慣例、目錄結構、共用工具與執行方式,確保所有 E2E 測試保持一致性與可維護性。 +--- + +# E2E 端到端測試規範 (E2E Testing with Playwright) + +本技能定義了 Star ERP 系統中端到端 (E2E) 測試的實作標準,使用 Playwright 模擬真實使用者操作瀏覽器,驗證 UI 顯示與功能流程的正確性。 + +--- + +## 1. 專案結構 + +### 1.1 目錄配置 + +``` +star-erp/ +├── playwright.config.ts # Playwright 設定檔 +├── e2e/ # E2E 測試根目錄 +│ ├── helpers/ # 共用工具函式 +│ │ └── auth.ts # 登入 helper +│ ├── screenshots/ # 測試截圖存放 +│ ├── auth.spec.ts # 認證相關測試(登入、登出) +│ ├── inventory.spec.ts # 庫存模組測試 +│ ├── products.spec.ts # 商品模組測試 +│ └── {module}.spec.ts # 依模組命名 +├── playwright-report/ # HTML 測試報告(自動產生,已 gitignore) +└── test-results/ # 失敗截圖與錄影(自動產生,已 gitignore) +``` + +### 1.2 命名規範 + +| 項目 | 規範 | 範例 | +|---|---|---| +| 測試檔案 | 小寫,依模組命名 `.spec.ts` | `inventory.spec.ts` | +| 測試群組 | `test.describe('中文功能名稱')` | `test.describe('庫存查詢')` | +| 測試案例 | 中文描述「**應**」開頭 | `test('應顯示庫存清單')` | +| 截圖檔案 | `{module}-{scenario}.png` | `inventory-search-result.png` | + +--- + +## 2. 設定檔 (playwright.config.ts) + +### 2.1 核心設定 + +```typescript +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:8081', // Sail 開發伺服器 + screenshot: 'only-on-failure', // 失敗時自動截圖 + video: 'retain-on-failure', // 失敗時保留錄影 + trace: 'on-first-retry', // 重試時收集 trace + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); +``` + +### 2.2 重要注意事項 + +> [!IMPORTANT] +> `baseURL` 必須指向本機 Sail 開發伺服器(預設 `http://localhost:8081`)。 +> 確保測試前已執行 `./vendor/bin/sail up -d` 與 `./vendor/bin/sail npm run dev`。 + +--- + +## 3. 共用工具 (Helpers) + +### 3.1 登入 Helper + +位置:`e2e/helpers/auth.ts` + +```typescript +import { Page } from '@playwright/test'; + +/** + * 共用登入函式 + * 使用測試帳號登入 ERP 系統 + */ +export async function login(page: Page, username = 'mama', password = 'mama9453') { + await page.goto('/'); + await page.fill('#username', username); + await page.fill('#password', password); + await page.getByRole('button', { name: '登入系統' }).click(); + // 等待儀表板載入完成 + await page.waitForSelector('text=系統概況', { timeout: 10000 }); +} +``` + +### 3.2 使用方式 + +```typescript +import { login } from './helpers/auth'; + +test('應顯示庫存清單', async ({ page }) => { + await login(page); + await page.goto('/inventory/stock-query'); + // ...斷言 +}); +``` + +--- + +## 4. 測試撰寫規範 + +### 4.1 測試結構模板 + +```typescript +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('模組功能名稱', () => { + + // 若整個 describe 都需要登入,使用 beforeEach + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應正確顯示頁面標題與關鍵元素', async ({ page }) => { + await page.goto('/target-page'); + + // 驗證頁面標題 + await expect(page.getByText('頁面標題')).toBeVisible(); + + // 驗證表格存在 + await expect(page.locator('table')).toBeVisible(); + }); + + test('應能執行 CRUD 操作', async ({ page }) => { + // ... + }); +}); +``` + +### 4.2 斷言 (Assertions) 慣例 + +| 場景 | 優先使用 | 避免使用 | +|---|---|---| +| 驗證頁面載入 | `page.getByText('關鍵文字')` | `page.waitForURL()` ※ | +| 驗證元素存在 | `expect(locator).toBeVisible()` | `.count() > 0` | +| 驗證表格資料 | `page.locator('table tbody tr')` | 硬編碼行數 | +| 等待操作完成 | `expect().toBeVisible({ timeout })` | `page.waitForTimeout()` | + +> [!NOTE] +> ※ Star ERP 使用 Inertia.js,頁面導航不一定改變 URL(例如儀表板路由為 `/`)。 +> 因此**優先使用頁面內容驗證**,而非依賴 URL 變化。 + +### 4.3 選擇器優先順序 + +依照 Playwright 官方建議,選擇器優先順序為: + +1. **Role** — `page.getByRole('button', { name: '登入系統' })` +2. **Text** — `page.getByText('系統概況')` +3. **Label** — `page.getByLabel('帳號')` +4. **Placeholder** — `page.getByPlaceholder('請輸入...')` +5. **Test ID** — `page.getByTestId('submit-btn')`(需在元件加 `data-testid`) +6. **CSS** — `page.locator('#username')`(最後手段) + +### 4.4 禁止事項 + +```typescript +// ❌ 禁止:硬等待(不可預期的等待時間) +await page.waitForTimeout(5000); + +// ✅ 正確:等待特定條件 +await expect(page.getByText('操作成功')).toBeVisible({ timeout: 5000 }); + +// ❌ 禁止:在測試中寫死測試資料的 ID +await page.goto('/products/42/edit'); + +// ✅ 正確:從頁面互動導航 +await page.locator('table tbody tr').first().getByRole('button', { name: '編輯' }).click(); +``` + +--- + +## 5. 截圖與視覺回歸 + +### 5.1 手動截圖(文件用途) + +```typescript +// 成功截圖存於 e2e/screenshots/ +await page.screenshot({ + path: 'e2e/screenshots/inventory-list.png', + fullPage: true, +}); +``` + +### 5.2 視覺回歸測試(偵測 UI 變化) + +```typescript +test('庫存頁面 UI 應保持一致', async ({ page }) => { + await login(page); + await page.goto('/inventory/stock-query'); + // 比對截圖,pixel 級差異會報錯 + await expect(page).toHaveScreenshot('stock-query.png', { + maxDiffPixelRatio: 0.01, // 容許 1% 差異(動態資料) + }); +}); +``` + +> [!NOTE] +> 首次執行 `toHaveScreenshot()` 會自動建立基準截圖。 +> 後續執行會與基準比對,更新基準用:`npx playwright test --update-snapshots` + +--- + +## 6. 執行指令速查 + +```bash +# 執行所有 E2E 測試 +npx playwright test + +# 執行特定模組測試 +npx playwright test e2e/login.spec.ts + +# UI 互動模式(可視化瀏覽器操作) +npx playwright test --ui + +# 帶頭模式(顯示瀏覽器畫面) +npx playwright test --headed + +# 產生 HTML 報告並開啟 +npx playwright test --reporter=html +npx playwright show-report + +# 更新視覺回歸基準截圖 +npx playwright test --update-snapshots + +# 只執行特定測試案例(用 -g 篩選名稱) +npx playwright test -g "登入" + +# Debug 模式(逐步執行) +npx playwright test --debug +``` + +--- + +## 7. 開發檢核清單 (Checklist) + +### 新增頁面或功能時: + +- [ ] 是否已為新頁面建立對應的 `.spec.ts` 測試檔? +- [ ] 測試是否覆蓋主要的 Happy Path(正常操作流程)? +- [ ] 測試是否覆蓋關鍵的 Error Path(錯誤處理)? +- [ ] 共用的登入步驟是否使用 `helpers/auth.ts`? +- [ ] 斷言是否優先使用頁面內容而非 URL? +- [ ] 選擇器是否遵循優先順序(Role > Text > Label > CSS)? +- [ ] 測試是否可獨立執行(不依賴其他測試的狀態)? + +### 提交程式碼前: + +- [ ] 全部 E2E 測試是否通過?(`npx playwright test`) +- [ ] 是否有遺留的 `test.only` 或 `test.skip`? diff --git a/.gitignore b/.gitignore index c1f5f8f..975985c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,12 @@ docs/f6_1770350984272.xlsx .gitignore BOM表自動計算成本.md 公共事業費-類別維護.md + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +e2e/screenshots/ diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..4d2aaaf --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,14 @@ +import { Page } from '@playwright/test'; + +/** + * 共用登入函式 + * 使用測試帳號登入 Star ERP 系統 + */ +export async function login(page: Page, username = 'mama', password = 'mama9453') { + await page.goto('/'); + await page.fill('#username', username); + await page.fill('#password', password); + await page.getByRole('button', { name: '登入系統' }).click(); + // 等待儀表板載入完成 + await page.waitForSelector('text=系統概況', { timeout: 10000 }); +} diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts new file mode 100644 index 0000000..bc623b1 --- /dev/null +++ b/e2e/login.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +/** + * Star ERP 登入流程端到端測試 + */ +test.describe('登入功能', () => { + + test('頁面應正確顯示登入表單', async ({ page }) => { + // 前往登入頁面 + await page.goto('/'); + + // 驗證:帳號輸入框存在 + await expect(page.locator('#username')).toBeVisible(); + + // 驗證:密碼輸入框存在 + await expect(page.locator('#password')).toBeVisible(); + + // 驗證:登入按鈕存在 + await expect(page.getByRole('button', { name: '登入系統' })).toBeVisible(); + }); + + test('輸入錯誤的帳密應顯示錯誤訊息', async ({ page }) => { + await page.goto('/'); + + // 輸入錯誤的帳號密碼 + await page.fill('#username', 'wronguser'); + await page.fill('#password', 'wrongpassword'); + + // 點擊登入 + await page.getByRole('button', { name: '登入系統' }).click(); + + // 等待頁面回應 + await page.waitForTimeout(2000); + + // 驗證:應該還是停在登入頁面(未成功跳轉) + await expect(page).toHaveURL(/\//); + + // 驗證:頁面上應顯示某種錯誤提示(紅色文字或 toast) + // 先用寬鬆的檢查方式,後續可以根據實際錯誤訊息調整 + const hasError = await page.locator('.text-red-500, .text-red-600, [role="alert"], .toast, [data-sonner-toast]').count(); + expect(hasError).toBeGreaterThan(0); + }); + + test('輸入正確帳密後應成功登入並跳轉', async ({ page }) => { + // 使用共用登入函式 + await login(page); + + // 驗證:頁面右上角應顯示使用者名稱 + await expect(page.getByText('mama')).toBeVisible(); + + // 驗證:儀表板的關鍵指標卡片存在 + await expect(page.getByText('庫存總值')).toBeVisible(); + + // 截圖留存(成功登入畫面) + await page.screenshot({ path: 'e2e/screenshots/login-success.png', fullPage: true }); + }); + + test('空白帳密不應能登入', async ({ page }) => { + await page.goto('/'); + + // 不填任何東西,直接點登入 + await page.getByRole('button', { name: '登入系統' }).click(); + + // 等待頁面回應 + await page.waitForTimeout(1000); + + // 驗證:應該還在登入頁面 + await expect(page.locator('#username')).toBeVisible(); + }); + +}); diff --git a/package-lock.json b/package-lock.json index 16bf295..b2dee62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "tailwind-merge": "^3.4.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.0.3", "@types/react": "^19.2.7", @@ -83,6 +84,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -846,6 +848,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2847,6 +2865,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2856,6 +2875,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2866,6 +2886,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3001,6 +3022,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3285,7 +3307,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -5379,6 +5402,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5386,6 +5410,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5463,6 +5534,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5475,6 +5547,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5539,6 +5612,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5669,7 +5743,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6035,7 +6110,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -6360,6 +6436,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index ae0ff06..b6fa688 100644 --- a/package.json +++ b/package.json @@ -1,59 +1,60 @@ { - "$schema": "https://www.schemastore.org/package.json", - "name": "star-erp", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@types/node": "^25.0.3", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "axios": "^1.11.0", - "concurrently": "^9.0.1", - "laravel-vite-plugin": "^2.0.0", - "tailwindcss": "^4.0.0", - "typescript": "^5.9.3", - "vite": "^7.0.7" - }, - "dependencies": { - "@inertiajs/react": "^2.3.4", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tailwindcss/typography": "^0.5.19", - "@types/lodash": "^4.17.21", - "@vitejs/plugin-react": "^5.1.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "jsbarcode": "^3.12.1", - "lodash": "^4.17.21", - "lucide-react": "^0.562.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-hot-toast": "^2.6.0", - "react-markdown": "^10.1.0", - "recharts": "^3.7.0", - "remark-gfm": "^4.0.1", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0" - } + "$schema": "https://www.schemastore.org/package.json", + "name": "star-erp", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.9.3", + "vite": "^7.0.7" + }, + "dependencies": { + "@inertiajs/react": "^2.3.4", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/typography": "^0.5.19", + "@types/lodash": "^4.17.21", + "@vitejs/plugin-react": "^5.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "jsbarcode": "^3.12.1", + "lodash": "^4.17.21", + "lucide-react": "^0.562.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.6.0", + "react-markdown": "^10.1.0", + "recharts": "^3.7.0", + "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0" + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3563eb1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E 測試設定檔 + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './e2e', + + /* 平行執行測試 */ + fullyParallel: true, + + /* CI 環境下禁止 test.only */ + forbidOnly: !!process.env.CI, + + /* CI 環境下失敗重試 2 次 */ + retries: process.env.CI ? 2 : 0, + + /* CI 環境下單 worker */ + workers: process.env.CI ? 1 : undefined, + + /* 使用 HTML 報告 */ + reporter: 'html', + + /* 全域共用設定 */ + use: { + /* 本機開發伺服器位址 */ + baseURL: 'http://localhost:8081', + + /* 失敗時自動截圖 */ + screenshot: 'only-on-failure', + + /* 失敗時保留錄影 */ + video: 'retain-on-failure', + + /* 失敗重試時收集 trace */ + trace: 'on-first-retry', + }, + + /* 只使用 Chromium 進行測試(開發階段足夠) */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); From 3f7a625191e5916accf550870bff3b4690e1f165 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Fri, 6 Mar 2026 16:58:29 +0800 Subject: [PATCH 06/13] =?UTF-8?q?[DOCS]=20=E6=95=B4=E7=90=86=20README.md?= =?UTF-8?q?=20=E5=A4=9A=E7=A7=9F=E6=88=B6=E6=9E=B6=E6=A7=8B=E8=AA=AA?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 5e62d90..792cbf1 100644 --- a/README.md +++ b/README.md @@ -180,4 +180,3 @@ docker compose down - **多租戶**: - 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。 - 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。 - From 89291918fde48e5ed4f59f8abfe8f833463b6b6a Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 9 Mar 2026 13:48:06 +0800 Subject: [PATCH 07/13] =?UTF-8?q?[FEAT]=20=E5=AF=A6=E4=BD=9C=E9=85=8D?= =?UTF-8?q?=E6=96=B9=E8=88=87=E7=94=9F=E7=94=A2=E5=B7=A5=E5=96=AE=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E6=90=9C=E5=B0=8B=EF=BC=8C=E5=84=AA=E5=8C=96=E5=88=86?= =?UTF-8?q?=E9=A0=81=20RWD=EF=BC=8C=E5=B0=87=E5=80=89=E5=BA=AB=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E6=94=B9=E7=82=BA=E9=81=B8=E5=A1=AB=E4=B8=A6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/framework.md | 12 ++++- .../Components/Warehouse/WarehouseDialog.tsx | 3 +- resources/js/Components/shared/Pagination.tsx | 27 +++++++--- .../js/Pages/Admin/ActivityLog/Index.tsx | 12 ++--- resources/js/Pages/Production/Index.tsx | 49 +++++++++---------- .../js/Pages/Production/Recipe/Index.tsx | 48 +++++++++--------- resources/js/utils/validation.ts | 4 -- 7 files changed, 82 insertions(+), 73 deletions(-) diff --git a/.agents/rules/framework.md b/.agents/rules/framework.md index 3d14c50..4c74ce2 100644 --- a/.agents/rules/framework.md +++ b/.agents/rules/framework.md @@ -87,4 +87,14 @@ trigger: always_on ## 10. 部署與查修環境 (CI/CD & Troubleshooting) * **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。 * **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`。 -* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。 \ No newline at end of file +* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。 + +## 11. 瀏覽器測試規範 (Browser Testing) +當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊: + +* **本地測試網址**:`http://localhost:8081/` +* **預設管理員帳號**:`admin` +* **預設管理員密碼**:`password` + +> [!IMPORTANT] +> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port,以避免連線至錯誤的服務環境。 \ No newline at end of file diff --git a/resources/js/Components/Warehouse/WarehouseDialog.tsx b/resources/js/Components/Warehouse/WarehouseDialog.tsx index fab0c2a..9da26a2 100644 --- a/resources/js/Components/Warehouse/WarehouseDialog.tsx +++ b/resources/js/Components/Warehouse/WarehouseDialog.tsx @@ -266,14 +266,13 @@ export default function WarehouseDialog({ {/* 倉庫地址 */}
setFormData({ ...formData, address: e.target.value })} placeholder="例:台北市信義區信義路五段7號" - required className="h-9" />
diff --git a/resources/js/Components/shared/Pagination.tsx b/resources/js/Components/shared/Pagination.tsx index 7a9cbd6..35eed90 100644 --- a/resources/js/Components/shared/Pagination.tsx +++ b/resources/js/Components/shared/Pagination.tsx @@ -27,22 +27,33 @@ export default function Pagination({ links, className }: PaginationProps) { const isNext = label === "Next"; const activeIndex = links.findIndex(l => l.active); - // Tablet/Mobile visibility logic (< md): - // Show: Previous, Next, Active, and up to 2 neighbors (Total ~5 numeric pages) - // Hide others on small screens (hidden md:flex) - // User requested: "small than 800... display 5 pages" - const isVisibleOnTablet = + // Responsive visibility logic: + // Global: Previous, Next, Active are always visible + // Mobile (< sm): Active, +-1, First, Last, and Ellipses + // Tablet (sm < md): Active, +-2, First, Last, and Ellipses + // Desktop (>= md): All standard pages + const isFirst = key === 1; + const isLast = key === links.length - 2; + const isEllipsis = !isPrevious && !isNext && !link.url; + + const isMobileVisible = isPrevious || isNext || link.active || + isFirst || + isLast || + isEllipsis || key === activeIndex - 1 || - key === activeIndex + 1 || + key === activeIndex + 1; + + const isTabletVisible = + isMobileVisible || key === activeIndex - 2 || key === activeIndex + 2; const baseClasses = cn( - isVisibleOnTablet ? "flex" : "hidden md:flex", - "h-9 items-center justify-center rounded-md border px-3 text-sm" + "h-9 items-center justify-center rounded-md border px-3 text-sm", + isMobileVisible ? "flex" : (isTabletVisible ? "hidden sm:flex md:flex" : "hidden md:flex") ); // 如果是 Previous/Next 但沒有 URL,則不渲染(或者渲染為 disabled) diff --git a/resources/js/Pages/Admin/ActivityLog/Index.tsx b/resources/js/Pages/Admin/ActivityLog/Index.tsx index ef9962a..ca0011c 100644 --- a/resources/js/Pages/Admin/ActivityLog/Index.tsx +++ b/resources/js/Pages/Admin/ActivityLog/Index.tsx @@ -318,10 +318,10 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u from={activities.from} /> -
-
+
+
- 每頁顯示 + 每頁顯示 - +
- 共 {activities.total} 筆資料 + 共 {activities.total} 筆資料
-
+
diff --git a/resources/js/Pages/Production/Index.tsx b/resources/js/Pages/Production/Index.tsx index 5a633c3..9ca8101 100644 --- a/resources/js/Pages/Production/Index.tsx +++ b/resources/js/Pages/Production/Index.tsx @@ -2,8 +2,9 @@ * 生產工單管理主頁面 */ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react'; +import { debounce } from "lodash"; import { formatQuantity } from "@/lib/utils"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; @@ -77,16 +78,25 @@ export default function ProductionIndex({ productionOrders, filters }: Props) { setPerPage(filters.per_page || productionOrders.per_page?.toString() || "10"); }, [filters]); - const handleFilter = () => { - router.get( - route('production-orders.index'), - { - search, - status: status === 'all' ? undefined : status, - per_page: perPage, - }, - { preserveState: true, replace: true, preserveScroll: true } - ); + const debouncedFilter = useCallback( + debounce((params: any) => { + router.get(route("production-orders.index"), params, { + preserveState: true, + replace: true, + preserveScroll: true, + }); + }, 300), + [] + ); + + const handleSearchChange = (term: string) => { + setSearch(term); + debouncedFilter({ + ...filters, + search: term, + status: status === "all" ? undefined : status, + per_page: perPage, + }); }; @@ -129,16 +139,12 @@ export default function ProductionIndex({ productionOrders, filters }: Props) { setSearch(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} className="pl-10 pr-10 h-9" - onKeyDown={(e) => e.key === 'Enter' && handleFilter()} /> {search && ( - - + + +
+``` + +## 11.7 日期顯示規範 (Date Display) 前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。 diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php index 296d85b..16b7daa 100644 --- a/app/Modules/Production/Controllers/ProductionOrderController.php +++ b/app/Modules/Production/Controllers/ProductionOrderController.php @@ -134,15 +134,15 @@ class ProductionOrderController extends Controller public function store(Request $request) { $status = $request->input('status', 'draft'); - + $rules = [ 'product_id' => 'required', - 'status' => 'nullable|in:draft,completed', - 'warehouse_id' => $status === 'completed' ? 'required' : 'nullable', - 'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric', + 'status' => 'nullable|in:draft,pending,completed', + 'warehouse_id' => 'required', + 'output_quantity' => 'required|numeric|min:0.01', 'items' => 'nullable|array', - 'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable', - 'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric', + 'items.*.inventory_id' => 'required', + 'items.*.quantity_used' => 'required|numeric|min:0.0001', ]; $validated = $request->validate($rules); @@ -159,7 +159,7 @@ class ProductionOrderController extends Controller 'production_date' => $request->production_date, 'expiry_date' => $request->expiry_date, 'user_id' => auth()->id(), - 'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿 + 'status' => $status ?: ProductionOrder::STATUS_DRAFT, 'remark' => $request->remark, ]); @@ -414,6 +414,19 @@ class ProductionOrderController extends Controller return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403); } + // 送審前的資料完整性驗證 + if ($productionOrder->status === ProductionOrder::STATUS_DRAFT && $newStatus === ProductionOrder::STATUS_PENDING) { + if (!$productionOrder->output_quantity || $productionOrder->output_quantity <= 0) { + return back()->with('error', '送審工單前,必須先編輯填寫「生產數量」'); + } + if (!$productionOrder->warehouse_id) { + return back()->with('error', '送審工單前,必須先編輯選擇「預計入庫倉庫」'); + } + if ($productionOrder->items()->count() === 0) { + return back()->with('error', '送審工單前,請至少新增一項原物料明細'); + } + } + DB::transaction(function () use ($newStatus, $productionOrder, $request) { // 使用鎖定重新獲取單據,防止併發狀態修改 $productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first(); @@ -444,6 +457,8 @@ class ProductionOrderController extends Controller $warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來 $batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來 $expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來 + $actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量 + $lossReason = $request->input('loss_reason'); // 耗損原因 if (!$warehouseId) { throw new \Exception('必須選擇入庫倉庫'); @@ -451,8 +466,14 @@ class ProductionOrderController extends Controller if (!$batchNumber) { throw new \Exception('必須提供成品批號'); } + if (!$actualOutputQuantity || $actualOutputQuantity <= 0) { + throw new \Exception('實際產出數量必須大於 0'); + } + if ($actualOutputQuantity > $productionOrder->output_quantity) { + throw new \Exception('實際產出數量不可大於預計產量'); + } - // --- 新增:計算原物料投入總成本 --- + // --- 計算原物料投入總成本 --- $totalCost = 0; $items = $productionOrder->items()->with('inventory')->get(); foreach ($items as $item) { @@ -461,23 +482,25 @@ class ProductionOrderController extends Controller } } - // 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤) - $unitCost = $productionOrder->output_quantity > 0 - ? $totalCost / $productionOrder->output_quantity + // 單位成本以「實際產出數量」為分母,反映真實生產效率 + $unitCost = $actualOutputQuantity > 0 + ? $totalCost / $actualOutputQuantity : 0; - // -------------------------------- - // 更新單據資訊:批號、效期與自動記錄生產日期 + // 更新單據資訊:批號、效期、實際產量與耗損原因 $productionOrder->output_batch_number = $batchNumber; $productionOrder->expiry_date = $expiryDate; $productionOrder->production_date = now()->toDateString(); $productionOrder->warehouse_id = $warehouseId; + $productionOrder->actual_output_quantity = $actualOutputQuantity; + $productionOrder->loss_reason = $lossReason; + // 成品入庫數量改用「實際產出數量」 $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $warehouseId, 'product_id' => $productionOrder->product_id, - 'quantity' => $productionOrder->output_quantity, - 'unit_cost' => $unitCost, // 傳入計算後的單位成本 + 'quantity' => $actualOutputQuantity, + 'unit_cost' => $unitCost, 'batch_number' => $batchNumber, 'box_number' => $productionOrder->output_box_count, 'arrival_date' => now()->toDateString(), diff --git a/app/Modules/Production/Models/ProductionOrder.php b/app/Modules/Production/Models/ProductionOrder.php index bc95e0d..c409034 100644 --- a/app/Modules/Production/Models/ProductionOrder.php +++ b/app/Modules/Production/Models/ProductionOrder.php @@ -24,6 +24,8 @@ class ProductionOrder extends Model 'product_id', 'warehouse_id', 'output_quantity', + 'actual_output_quantity', + 'loss_reason', 'output_batch_number', 'output_box_count', 'production_date', @@ -82,6 +84,7 @@ class ProductionOrder extends Model 'production_date' => 'date', 'expiry_date' => 'date', 'output_quantity' => 'decimal:2', + 'actual_output_quantity' => 'decimal:2', ]; public function getActivitylogOptions(): LogOptions @@ -91,6 +94,8 @@ class ProductionOrder extends Model 'code', 'status', 'output_quantity', + 'actual_output_quantity', + 'loss_reason', 'output_batch_number', 'production_date', 'remark' diff --git a/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php b/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php new file mode 100644 index 0000000..4c80abc --- /dev/null +++ b/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php @@ -0,0 +1,37 @@ +decimal('actual_output_quantity', 10, 2) + ->nullable() + ->after('output_quantity') + ->comment('實際產出數量(預設等於 output_quantity,可於完工時調降)'); + + $table->string('loss_reason', 255) + ->nullable() + ->after('actual_output_quantity') + ->comment('耗損原因說明'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('production_orders', function (Blueprint $table) { + $table->dropColumn(['actual_output_quantity', 'loss_reason']); + }); + } +}; diff --git a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx index c500cb6..9e2adda 100644 --- a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx +++ b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx @@ -1,5 +1,6 @@ /** * 生產工單完工入庫 - 選擇倉庫彈窗 + * 含產出確認與耗損記錄功能 */ import React from 'react'; @@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Input } from "@/Components/ui/input"; import { Label } from "@/Components/ui/label"; -import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react"; +import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react"; +import { formatQuantity } from "@/lib/utils"; interface Warehouse { id: number; @@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps { warehouseId: number; batchNumber: string; expiryDate: string; + actualOutputQuantity: number; + lossReason: string; }) => void; warehouses: Warehouse[]; processing?: boolean; - // 新增商品資訊以利產生批號 + // 商品資訊用於產生批號 productCode?: string; productId?: number; + // 預計產量(用於耗損計算) + outputQuantity: number; + // 成品單位名稱 + unitName?: string; } export default function WarehouseSelectionModal({ @@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({ processing = false, productCode, productId, + outputQuantity, + unitName = '', }: WarehouseSelectionModalProps) { const [selectedId, setSelectedId] = React.useState(null); const [batchNumber, setBatchNumber] = React.useState(""); const [expiryDate, setExpiryDate] = React.useState(""); + const [actualOutputQuantity, setActualOutputQuantity] = React.useState(""); + const [lossReason, setLossReason] = React.useState(""); + + // 當開啟時,初始化實際產出數量為預計產量 + React.useEffect(() => { + if (isOpen) { + setActualOutputQuantity(String(outputQuantity)); + setLossReason(""); + } + }, [isOpen, outputQuantity]); // 當開啟時,嘗試產生成品批號 (若有資訊) React.useEffect(() => { @@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({ } }, [isOpen, productCode, productId]); + // 計算耗損數量 + const actualQty = parseFloat(actualOutputQuantity) || 0; + const lossQuantity = outputQuantity - actualQty; + const hasLoss = lossQuantity > 0; + const handleConfirm = () => { - if (selectedId && batchNumber) { + if (selectedId && batchNumber && actualQty > 0) { onConfirm({ warehouseId: selectedId, batchNumber, - expiryDate + expiryDate, + actualOutputQuantity: actualQty, + lossReason: hasLoss ? lossReason : '', }); } }; + // 驗證:實際產出不可大於預計產量,也不可小於等於 0 + const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity; + return ( !open && onClose()}> - + - 選擇完工入庫倉庫 + 完工入庫確認
+ {/* 倉庫選擇 */}
+ {/* 成品批號 */}
+ {/* 成品效期 */}
+ + {/* 分隔線 - 產出確認區 */} +
+

產出確認

+ + {/* 預計產量(唯讀) */} +
+ 預計產量 + + {formatQuantity(outputQuantity)} {unitName} + +
+ + {/* 實際產出數量 */} +
+ +
+ setActualOutputQuantity(e.target.value)} + className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`} + /> + {unitName && {unitName}} +
+ {actualQty > outputQuantity && ( +

實際產出不可超過預計產量

+ )} +
+ + {/* 耗損顯示 */} + {hasLoss && ( +
+
+ + + 耗損數量:{formatQuantity(lossQuantity)} {unitName} + +
+
+ + setLossReason(e.target.value)} + placeholder="例如:製作過程損耗、品質不合格..." + className="h-9 border-orange-200 focus:ring-orange-400" + /> +
+
+ )} +
+
+ +
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) { }))} placeholder="選擇成品" className="w-full h-9" + aria-invalid={!!errors.product_id} /> {errors.product_id &&

{errors.product_id}

} @@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) { onChange={(e) => setData('output_quantity', e.target.value)} placeholder="例如: 50" className="h-9 font-mono" + aria-invalid={!!errors.output_quantity} /> {errors.output_quantity &&

{errors.output_quantity}

}
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) { }))} placeholder="選擇倉庫" className="w-full h-9" + aria-invalid={!!errors.warehouse_id} /> {errors.warehouse_id &&

{errors.warehouse_id}

} @@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) { options={productOptions} placeholder="選擇商品" className="w-full" + aria-invalid={!!errors[`items.${index}.ui_product_id` as any]} />
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) { placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"} className="w-full" disabled={!item.ui_warehouse_id} + aria-invalid={!!errors[`items.${index}.inventory_id` as any]} /> {item.inventory_id && (() => { const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); @@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) { placeholder="0" className="h-9 text-right" disabled={!item.inventory_id} + aria-invalid={!!errors[`items.${index}.quantity_used` as any]} />
diff --git a/resources/js/Pages/Production/Show.tsx b/resources/js/Pages/Production/Show.tsx index acb6e3c..7da30d6 100644 --- a/resources/js/Pages/Production/Show.tsx +++ b/resources/js/Pages/Production/Show.tsx @@ -56,6 +56,8 @@ interface ProductionOrder { output_batch_number: string; output_box_count: string | null; output_quantity: number; + actual_output_quantity: number | null; + loss_reason: string | null; production_date: string; expiry_date: string | null; status: ProductionOrderStatus; @@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr warehouseId?: number; batchNumber?: string; expiryDate?: string; + actualOutputQuantity?: number; + lossReason?: string; }) => { router.patch(route('production-orders.update-status', productionOrder.id), { status: newStatus, warehouse_id: extraData?.warehouseId, output_batch_number: extraData?.batchNumber, expiry_date: extraData?.expiryDate, + actual_output_quantity: extraData?.actualOutputQuantity, + loss_reason: extraData?.lossReason, }, { onSuccess: () => { setIsWarehouseModalOpen(false); @@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr processing={processing} productCode={productionOrder.product?.code} productId={productionOrder.product?.id} + outputQuantity={Number(productionOrder.output_quantity)} + unitName={productionOrder.product?.base_unit?.name} />
@@ -276,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr

-

預計/實際產量

+

預計產量

{formatQuantity(productionOrder.output_quantity)} @@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr )}

+ {/* 實際產量與耗損(僅完成狀態顯示) */} + {productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && ( +
+

實際產量

+
+

+ {formatQuantity(productionOrder.actual_output_quantity)} +

+ {productionOrder.product?.base_unit?.name && ( + {productionOrder.product.base_unit.name} + )} + {Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && ( + + 耗損 {formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))} + + )} +
+ {productionOrder.loss_reason && ( +

原因:{productionOrder.loss_reason}

+ )} +
+ )}

入庫倉庫