Compare commits
481 Commits
b573030aac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd0a047f2 | |||
| e3ceedc579 | |||
| f2e5ef121e | |||
| 7a1fc02dfc | |||
| 5f34855233 | |||
| bee8ecb55b | |||
| 0198b7de90 | |||
| b57a4feeab | |||
| 55a806051d | |||
| 6ca0bafd60 | |||
| adf13410ba | |||
| d52a215916 | |||
| 197df3bec4 | |||
| 2437aa2672 | |||
| a987f4345e | |||
| 89291918fd | |||
| 3f7a625191 | |||
| e11193c2a7 | |||
| 02e5f5d4ea | |||
| 36b90370a8 | |||
| 5290dd2cbe | |||
| 8e0252e8fc | |||
| 2fd5de96b2 | |||
| 951005c616 | |||
| ee0bacafc2 | |||
| 93390aad80 | |||
| 6b6e840f35 | |||
| e4c83ebd6d | |||
| d9edc603c7 | |||
| dda92393d2 | |||
| 47deab9804 | |||
| e921810f70 | |||
| 07b7d9b327 | |||
| 016366407c | |||
| ba50905626 | |||
| f4ed358393 | |||
| 7c395c89b5 | |||
| ed264b031a | |||
| dd2e63c08b | |||
| b498fe93ff | |||
| 6b324b4bd0 | |||
| a898873211 | |||
| f543b98d0f | |||
| 183583c739 | |||
| 58bd995cd8 | |||
| 036f4a4fb6 | |||
| 0a955fb993 | |||
| 7dac2d1f77 | |||
| 649af40919 | |||
| 5f8b2a1c2d | |||
| 4bbbde685d | |||
| 5e32526471 | |||
| f960aaaeb2 | |||
| 63e4f88a14 | |||
| e3df090afd | |||
| 878b90e2ad | |||
| 299cf37054 | |||
| 5668e17e61 | |||
| c4908533a8 | |||
| deef3baacc | |||
| ad91b08dbc | |||
| e406ecd63d | |||
| 455f945296 | |||
| aaa93a921e | |||
| 922b1fc877 | |||
| 3d4682a405 | |||
| 3ba6b3a1cd | |||
| ec239279f4 | |||
| e2c36e9c0f | |||
| 30bf8ef79c | |||
| 590d1ea9e9 | |||
| cd0f454c98 | |||
| 54e1e5df5a | |||
| 8e3d951d0d | |||
| d04e5bbffb | |||
| 27273bfee4 | |||
| 2a88649f75 | |||
| e9313158ba | |||
| f3da49a76a | |||
| 747f70865d | |||
| 6bb2afa3b7 | |||
| 59008eb59e | |||
| a33e470e4d | |||
| 71b676b533 | |||
| 406d03297a | |||
| 4259c7745b | |||
| 8169ff3f59 | |||
| 1acc4daebb | |||
| 1acbfb7246 | |||
| e02d7c7125 | |||
| a133b94a05 | |||
| acd0590a38 | |||
| a2fe7b5a95 | |||
| 5f1f08869f | |||
| e85c1fa95a | |||
| 62dcf04e95 | |||
| 6dd3396fb7 | |||
| 2f30a78118 | |||
| 904132e460 | |||
| a05acd96dc | |||
| 29cdf37b71 | |||
| d7d1be81a9 | |||
| 227cfec0d2 | |||
| 034a21cd31 | |||
| 6358e23816 | |||
| ac149533f0 | |||
| b20a47f710 | |||
| d017d7e5e0 | |||
| 8207e6fe94 | |||
| e6cf03b991 | |||
| 8ef82d49cb | |||
| bb2cf77ccb | |||
| d543e6e810 | |||
| 64e039cc71 | |||
| cce8dd3c8b | |||
| 6a0f57c86c | |||
| 0251540365 | |||
| ab5b4bde0b | |||
| f85f06f3e1 | |||
| 6671e4221f | |||
| 24f73a2585 | |||
| 2e9ff6c832 | |||
| 77a7d31dc1 | |||
| e141a45eb9 | |||
| 4fa87925a2 | |||
| 097708aab7 | |||
| b8cbf0bb6d | |||
| 882091ce5f | |||
| 245553280a | |||
| 299602d3b1 | |||
| 96f2ccee95 | |||
| c9113544ee | |||
| 5be4d49679 | |||
| b118ea0c39 | |||
| eb5ab58093 | |||
| 57e633c3e9 | |||
| 448b37ca90 | |||
| 6c146ac717 | |||
| cb433035fe | |||
| e646c6ffd8 | |||
| 83e1c82b11 | |||
| 19397db2e9 | |||
| db285a6b69 | |||
| 74eeb449f8 | |||
| 28ece9fda4 | |||
| bd292b0868 | |||
| ac705a1e58 | |||
| 936abc943e | |||
| eabde37d15 | |||
| 921f6e48fb | |||
| ba4ceb7ff6 | |||
| 3be5d099c9 | |||
| 9537e48f08 | |||
| 165737750c | |||
| 220478641d | |||
| 593ce94734 | |||
| 8b950f6529 | |||
| e098e40fb8 | |||
| 83d26de6f9 | |||
| 38642cc58b | |||
| a6393e03d8 | |||
| 6980eac1a4 | |||
| 08e360464e | |||
| 7cf640b2f4 | |||
| 613eb555ba | |||
| 65eb1a1b64 | |||
| b6fe9ad9f3 | |||
| 590580e20a | |||
| c2e0ff726d | |||
| 5e542752ba | |||
| f22df90e01 | |||
| e018b75783 | |||
| 200d1989bd | |||
| 6c259859cf | |||
| 6bfdd92347 | |||
| 70f1709bd0 | |||
| 3fd333085b | |||
| 906b094c18 | |||
| e1aa452b3c | |||
| 397a8a6484 | |||
| 24aed44cd3 | |||
| 196fec3120 | |||
| 096a114457 | |||
| af06ca7695 | |||
| 1d5bc68444 | |||
| 075b9f1c98 | |||
| 49bb05d85a | |||
| 687af254bd | |||
| a518d390bd | |||
| ba3c10ac13 | |||
| dada3a6512 | |||
| b99e391cc6 | |||
| 0aa7fd1f75 | |||
| 3ce96537b3 | |||
| 04f3891275 | |||
| 4299e985e9 | |||
| 2eb136d280 | |||
| 88415505fb | |||
| 702af0a259 | |||
| f4f597e96d | |||
| a8b88b3375 | |||
| 95fdec8a06 | |||
| 4ba85ce446 | |||
| a0c450d229 | |||
| 16967fc25d | |||
| 29842510c4 | |||
| 19216f5846 | |||
| bd999c7bb6 | |||
| 15aaa039e4 | |||
| 27626e6aa8 | |||
| a160e3f15f | |||
| d671c08338 | |||
| 0185843c62 | |||
| be5c121146 | |||
| f87310e707 | |||
| b0192e9b66 | |||
| 8a34aae312 | |||
| 6204f0d915 | |||
| df3db38dd4 | |||
| 75c634ffe4 | |||
| 1748eb007e | |||
| 313b95ceb9 | |||
| 5e897e4197 | |||
| 71458dd976 | |||
| 36ef411975 | |||
| bb78a432f5 | |||
| 0d720f3515 | |||
| 2e71a1cb29 | |||
| 746eeb6f01 | |||
| 7619dc24f7 | |||
| 2efaded77b | |||
| a31c8d6052 | |||
| 56e30a85bb | |||
| 46753cc3bc | |||
| 7f726e80bd | |||
| 8bc95db43d | |||
| 95a1763d04 | |||
| 90cb7a82de | |||
| bbb2c4c4a3 | |||
| 8cb95e1a56 | |||
| fc59c86305 | |||
| b613cdb796 | |||
| b1745555cc | |||
| 1833ca192d | |||
| e5edad4fd0 | |||
| 852370cfe0 | |||
| 965418077b | |||
| c3af92c85c | |||
| cca49b5fe8 | |||
| d4cef2cd84 | |||
| 4c959efc8b | |||
| 95d8dc2e84 | |||
| a7c445bd3f | |||
| 293358df62 | |||
| 1ed3d6a29d | |||
| 646435f87a | |||
| f10c31abd0 | |||
| 046e0a028b | |||
| ce0a7b3409 | |||
| 084bbc9f53 | |||
| 3af4a1e298 | |||
| 9b0e3b4f6f | |||
| 0e51992cb4 | |||
| ac6a81b3d2 | |||
| 106de4e945 | |||
| b0848a6bb8 | |||
| db0c1ce3af | |||
| 1d134c9ad8 | |||
| 1ae21febb5 | |||
| fc20c6d813 | |||
| af5f2f55ab | |||
| eab9e2ce93 | |||
| 8215b42e43 | |||
| db49f417df | |||
| 9e574fea85 | |||
| 7eed761861 | |||
| b3299618ce | |||
| 9a50bbf887 | |||
| 89183ca124 | |||
| 74728c47b9 | |||
| daae429cd4 | |||
| b2a63bd1ed | |||
| 7bf892db19 | |||
| a41d3d8f55 | |||
| 239e547a5d | |||
| c1d302f03e | |||
| 32c2612a5f | |||
| 8928a84ff9 | |||
| 23682b3ffe | |||
| 7367577f6a | |||
| 5c4693577a | |||
| 632dee13a5 | |||
| cdcc0f4ce3 | |||
| f6167fdaec | |||
| b29278aa12 | |||
| ed6fb37ec3 | |||
| 6bd52fe3db | |||
| f83baffddb | |||
| a8091276b8 | |||
| 18edb3cb69 | |||
| 74417e2e31 | |||
| 0d7bb2758d | |||
| 19c2eeba7b | |||
| 55272d5d43 | |||
| a2c99e3a36 | |||
| 43d7cada34 | |||
| 5b15ca2cd6 | |||
| aa4143ccf1 | |||
| 8a9b8135bd | |||
| 736a01f198 | |||
| 32f993a6e1 | |||
| 231d1ad029 | |||
| c7e1154af8 | |||
| d28671b60c | |||
| 4b2ccd36b8 | |||
| b685c818a4 | |||
| bf48fe0c35 | |||
| 2b752b51ff | |||
| 9bc7c8514b | |||
| 287ac6faa3 | |||
| 9ce8ff4e06 | |||
| a6b5496529 | |||
| 79e5916d19 | |||
| a6ed2720d5 | |||
| 190d6c2bd9 | |||
| a64a4682f3 | |||
| 4f745c1021 | |||
| 3e3d8ffb6c | |||
| 74a084d938 | |||
| f7238c2860 | |||
| 7dfe46ff9a | |||
| 8e364bc2f7 | |||
| 2e166d44d2 | |||
| 78a7ca4261 | |||
| e3afc0b64a | |||
| 4d6d37743e | |||
| a0a61ba683 | |||
| 2e7aeef367 | |||
| 566dfa31ae | |||
| f18fb169f3 | |||
| 6600cde3bc | |||
| f0e6c6e4d1 | |||
| ecfcbb93ed | |||
| 6770a4ec2f | |||
| b17e305374 | |||
| 7ffbc2b1ea | |||
| 4f85f80f8e | |||
| 4e24b70af3 | |||
| ff66b295e1 | |||
| d736bf9802 | |||
| 7c1ee40882 | |||
| 9793ab774b | |||
| 5da14da58e | |||
| 6e174a09a5 | |||
| 0f45a539de | |||
| 268fc10ded | |||
| 822a83f700 | |||
| c84d6f7600 | |||
| 1dd50473ce | |||
| 24ae6f3eee | |||
| d60367ac57 | |||
| 3088959c7c | |||
| cbd8d11848 | |||
| 0b60dab208 | |||
| 7848976a06 | |||
| 48115082e5 | |||
| eca2f38395 | |||
| 807790a7fc | |||
| f9f0d09195 | |||
| ef1fc47cff | |||
| 19c60a6126 | |||
| 8ea1ce1515 | |||
| d852d7b2ec | |||
| 720c9a176a | |||
| 1bd89dcf2e | |||
| 2c7d249014 | |||
| bd51911561 | |||
| bd29410191 | |||
| be315a76cc | |||
| fad74df6ac | |||
| 7160a7e780 | |||
| 3e28067c97 | |||
| fd3ddd0bac | |||
| 5797ff118d | |||
| 41d5e8e7fc | |||
| f4ca6b09e8 | |||
| 1c8c3009ec | |||
| 315cce467e | |||
| 001ba33335 | |||
| 6209b28345 | |||
| 1759fceaed | |||
| 54d36f51e7 | |||
| 981d887ae8 | |||
| 8e91f28ef4 | |||
| fbcdcd05b0 | |||
| 8d838ee6f6 | |||
| fd86ae0153 | |||
| cdf434d63c | |||
| ccdbe48b88 | |||
| 564c6588c1 | |||
| 21d0ea4cc2 | |||
| 6b0f3c9bcd | |||
| 0aaa761a47 | |||
| d683861233 | |||
| b240877d40 | |||
| e31715becb | |||
| 6631f64e4b | |||
| 04e4ec0dca | |||
| 5dacae85d6 | |||
| ab2d41720a | |||
| e5715b7274 | |||
| c40e331f2b | |||
| 91c86bffff | |||
| 3a6522bfe0 | |||
| 36032e3d08 | |||
| 6fdcf74d2c | |||
| 8a5ff37860 | |||
| 56e2f92e6b | |||
| 01386b7aa2 | |||
| 94e3fac0f5 | |||
| 488b5925da | |||
| fd65fa23e0 | |||
| ca34636246 | |||
| a6112b8a65 | |||
| cc83a26375 | |||
| b8995ece05 | |||
| 0112672b0a | |||
| b1b381115a | |||
| fbc5fb962a | |||
| 9f0bf51b7f | |||
| 1150dafe13 | |||
| 97935f513c | |||
| e6ef25951a | |||
| 10fd897685 | |||
| 6d87e3e030 | |||
| 78325bf0c7 | |||
| 3590da83c3 | |||
| 91cd942239 | |||
| a81fc17c60 | |||
| c078d0184b | |||
| 09e075a56d | |||
| 17b6382bf1 | |||
| 5e10459652 | |||
| b948ebd7fe | |||
| 841f987935 | |||
| 1ac8c70bd7 | |||
| a74709b75d | |||
| a2bad91b86 | |||
| 64990f0fb2 | |||
| ebbd5cdc80 | |||
| 477186bc3c | |||
| 080000102b | |||
| a275931f97 | |||
| 5fe0cb8cb6 | |||
| cda5650962 | |||
| 6ecb513bb0 | |||
| e11c1d3bb3 | |||
| dfce5287fc | |||
| 9be253a548 | |||
| a3372cc24c | |||
| 13c960a90e | |||
| 158c7154f3 | |||
| 58fc0a7de4 | |||
| fe85566d48 | |||
| a5023d3318 | |||
| 3b6442f487 | |||
| 0746f752ac | |||
| af26e5f1b2 | |||
| 3d6560fd46 | |||
| 35cf9e3cd3 | |||
| 695799e6bc | |||
| 128dc50939 | |||
| f4cf16ddec | |||
| f73284420b | |||
| 4c7abf463e | |||
| e76b8e6a90 | |||
| 38b9f96ee4 | |||
| 31ecccf913 | |||
| da758b9891 | |||
| b857f7a29d | |||
| 24c694b201 |
@@ -1,74 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
預設專案運行於 WSL2 的 Laravel Sail (Docker) 環境。
|
||||
開發框架規範說明書:ERP 系統 (koori-erp)
|
||||
1. 專案概述
|
||||
目標: 打造一個強大且穩定的 ERP 後台管理系統。
|
||||
|
||||
核心架構: 採用 單體式架構配現代化前端 (Monolith with a Modern Frontend)。使用 Laravel、Inertia.js 及 React。
|
||||
|
||||
工作流程: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。
|
||||
|
||||
2. 技術棧 (Tech Stack)
|
||||
後端: PHP 8.5 / Laravel 12
|
||||
|
||||
前端橋樑: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
|
||||
|
||||
前端庫: React (以 Functional Components 與 Hooks 為主)
|
||||
|
||||
樣式處理: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
|
||||
|
||||
資料庫: MySQL 8.0
|
||||
|
||||
開發環境: Laravel Sail (Docker / WSL2)
|
||||
|
||||
未來擴充: 針對高併發或跨平台模組,預留 Golang 微服務接口。
|
||||
|
||||
3. 目錄結構與慣例
|
||||
3.1 後端 (Laravel)
|
||||
Controllers: 必須回傳 Inertia::render() 來渲染頁面。
|
||||
|
||||
Models: 嚴格執行型別標註,使用 Eloquent 進行資料庫操作。
|
||||
|
||||
Routes: 統一在 routes/web.php 定義 Inertia 路由。
|
||||
|
||||
3.2 前端 (React)
|
||||
Pages (頁面): 位於 resources/js/Pages/。每個檔案代表一個完整的路由視圖。
|
||||
|
||||
Components (組件): 位於 resources/js/Components/。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
|
||||
|
||||
Layouts (版面): 位於 resources/js/Layouts/。定義 ERP 的通用版面(例如:包含側邊欄 Sidebar 與導覽列 Navbar 的後台主框架)。
|
||||
|
||||
4. 整合指南 (UI/UX 轉換至 Laravel)
|
||||
組件遷移: 將 UI/UX 的 React 原始碼移入 resources/js/ 時,應進行「原子化」拆解,提高元件複用率。
|
||||
|
||||
資料傳遞: 透過 Laravel Controller 的 props 傳送動態資料給 React。除非是後續的異步請求,否則避免在 React 初次渲染時使用 axios 抓取資料,應優先使用 Inertia 的資料流。
|
||||
|
||||
狀態管理: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
|
||||
|
||||
5. 開發標準 (Coding Standards)
|
||||
命名規範:
|
||||
|
||||
Controllers: PascalCaseController.php
|
||||
|
||||
React Components: PascalCase.jsx
|
||||
|
||||
Routes: kebab-case (小寫橫線分隔)
|
||||
|
||||
回傳格式: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||
|
||||
6. AI 協作規則 (給 Antigravity AI)
|
||||
角色設定: 你是一位專業的全端開發工程師助手。
|
||||
|
||||
代碼生成指令:
|
||||
|
||||
所有的解釋說明請使用 繁體中文。
|
||||
|
||||
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||
|
||||
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
100
.agents/rules/framework.md
Normal file
100
.agents/rules/framework.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 開發框架規範說明書:ERP 系統 (star-erp)
|
||||
|
||||
## 1. 專案概述
|
||||
* **目標**: 打造一個強大且穩定的 ERP 後台管理系統。
|
||||
* **核心架構**: 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。
|
||||
* **工作流程**: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。
|
||||
|
||||
## 2. 技術棧 (Tech Stack)
|
||||
* **後端**: PHP 8.5 / Laravel 12
|
||||
* **前端橋樑**: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
|
||||
* **前端庫**: React (以 Functional Components 與 Hooks 為主)
|
||||
* **樣式處理**: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
|
||||
* **資料庫**: MySQL 8.0
|
||||
* **開發環境**: Laravel Sail (Docker / WSL2)
|
||||
* **未來擴充**: 針對高併發或跨平台模組,預留 Golang 微服務接口。
|
||||
|
||||
## 3. 目錄結構與慣例
|
||||
|
||||
### 3.1 後端 (Laravel - Modular Monolith)
|
||||
系統採用模組化架構,核心邏輯位於 `app/Modules/` 下:
|
||||
|
||||
* **Modules**: 位於 `app/Modules/{ModuleName}/`。
|
||||
* **Controllers**: `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`。
|
||||
* **Models**: `app/Modules/{ModuleName}/Models/`。
|
||||
* **Routes**: `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。
|
||||
* **Global Routes**: `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。
|
||||
|
||||
### 3.2 前端 (React)
|
||||
* **Pages (頁面)**: 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。
|
||||
* **Components (組件)**: 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
|
||||
* **Layouts (版面)**: 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。
|
||||
|
||||
## 4. 整合指南 (UI/UX 轉換至 Laravel)
|
||||
* **組件遷移**: 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。
|
||||
* **資料傳遞**: 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。
|
||||
* **狀態管理**: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
|
||||
|
||||
## 5. 開發標準 (Coding Standards)
|
||||
* **命名規範**:
|
||||
* Controllers: `PascalCaseController.php`
|
||||
* React Components: `PascalCase.jsx`
|
||||
* Routes: `kebab-case` (小寫橫線分隔)
|
||||
* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||
|
||||
## 6. 嚴格模組化通訊規範 (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)
|
||||
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
* 生成 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)
|
||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||
* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。
|
||||
* **指令執行**:
|
||||
* **Seeders**: 必須執行 `./vendor/bin/sail php artisan tenants:run db:seed` 以確保所有租戶均獲得更新。
|
||||
* **Tinker**: 檢查租戶資料時應使用 `./vendor/bin/sail php artisan tenants:run tinker`。
|
||||
* **Migrations**: 租戶相關的 Schema 異動應放在 `database/migrations/tenant/` 並執行 `./vendor/bin/sail artisan tenants:migrate`。
|
||||
|
||||
## 9. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
||||
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
||||
* **執行 Composer**: `./vendor/bin/sail composer install`
|
||||
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||
|
||||
## 10. 部署與查修環境 (CI/CD & Troubleshooting)
|
||||
* **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。
|
||||
* **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`。
|
||||
* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。
|
||||
|
||||
## 11. 瀏覽器測試規範 (Browser Testing)
|
||||
當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊:
|
||||
|
||||
* **本地測試網址**:`http://localhost:8081/`
|
||||
* **預設管理員帳號**:`admin`
|
||||
* **預設管理員密碼**:`password`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port,以避免連線至錯誤的服務環境。
|
||||
57
.agents/rules/skill-trigger.md
Normal file
57
.agents/rules/skill-trigger.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 技能觸發規範 (Skill Trigger Rules)
|
||||
|
||||
本文件確保 AI 助手在對話中能**主動辨識**需要參照技能 (Skill) 的時機。
|
||||
Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
**若對話內容命中以下任一觸發條件,必須先使用 `view_file` 讀取對應的 `SKILL.md` 後再進行作業。**
|
||||
|
||||
---
|
||||
|
||||
## 觸發對照表
|
||||
|
||||
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|
||||
|---|---|---|
|
||||
| 操作紀錄、Activity Log、日誌、`tapActivity`、`LogsActivity`、`saveQuietly`、`activity()`、`items_diff` | **操作紀錄實作規範** | `.agents/skills/activity-logging/SKILL.md` |
|
||||
| 權限、permission、角色、role、`usePermission`、`<Can>`、`PermissionSeeder`、middleware protection | **權限管理與實作規範** | `.agents/skills/permission-management/SKILL.md` |
|
||||
| 跨模組、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` |
|
||||
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
|
||||
|
||||
---
|
||||
|
||||
## 強制觸發場景
|
||||
|
||||
以下場景**無論對話中是否出現觸發詞**,都必須主動載入對應 Skill:
|
||||
|
||||
### 🔴 新增功能或頁面時
|
||||
必須同時讀取:
|
||||
1. **permission-management** — 設定權限
|
||||
2. **ui-consistency** — 遵循 UI 規範
|
||||
3. **activity-logging** — 若涉及 Model CRUD,需加上操作紀錄
|
||||
4. **e2e-testing** — 確認是否需要新增對應的 E2E 測試
|
||||
|
||||
### 🔴 新增或修改 Model 時
|
||||
必須讀取:
|
||||
1. **activity-logging** — `tapActivity` 實作
|
||||
2. **cross-module-communication** — 確認是否涉及跨模組引用
|
||||
|
||||
### 🔴 Git 操作時
|
||||
必須讀取:
|
||||
1. **git-workflows** — 分支命名與 commit 格式
|
||||
|
||||
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||
必須讀取:
|
||||
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 即使你「記得」Skill 的大致內容,仍必須重新讀取 `SKILL.md`。
|
||||
> 因為 Skill 文件可能已經更新,且記憶中的內容可能不完整。
|
||||
285
.agents/skills/activity-logging/SKILL.md
Normal file
285
.agents/skills/activity-logging/SKILL.md
Normal file
@@ -0,0 +1,285 @@
|
||||
---
|
||||
name: 操作紀錄實作規範 (Activity Logging Skill)
|
||||
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||
---
|
||||
|
||||
# 操作紀錄實作規範 (Activity Logging Skill)
|
||||
|
||||
本技能定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 啟用 Activity Log (Model 基本設定)
|
||||
|
||||
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
|
||||
|
||||
```php
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
|
||||
->dontSubmitEmptyLogs(); // 若無變動則不記錄
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `tapActivity` 實作規範 (Backend 核心)
|
||||
|
||||
### 2.1 型別宣告:統一使用 `Contracts\Activity`
|
||||
|
||||
```php
|
||||
// ✅ 正確:使用介面
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
|
||||
// ❌ 禁止:使用具體類別
|
||||
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
|
||||
```
|
||||
|
||||
### 2.2 必須 `toArray()` 避免 Indirect modification error
|
||||
|
||||
```php
|
||||
// 🚩 核心:轉換為陣列以避免 Indirect modification error
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
// ... 操作 $properties ...
|
||||
|
||||
$activity->properties = $properties; // 最後整體回寫
|
||||
```
|
||||
|
||||
### 2.3 Snapshot 快照策略
|
||||
|
||||
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊。
|
||||
|
||||
```php
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['doc_no'] = $this->doc_no; // 單號
|
||||
$snapshot['name'] = $this->name; // 名稱
|
||||
$snapshot['warehouse_name'] = $this->warehouse?->name; // 關聯名稱
|
||||
$properties['snapshot'] = $snapshot;
|
||||
```
|
||||
|
||||
### 2.4 全域 ID 轉名稱邏輯 (ID Resolution)
|
||||
|
||||
所有的 ID(如 `warehouse_id`, `created_by`)在記錄時應自動解析為名稱。
|
||||
|
||||
#### 模組內 Model:可直接查詢
|
||||
|
||||
```php
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 同模組內的 Model 可以直接查詢
|
||||
foreach (['created_by', 'updated_by', 'completed_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 跨模組 Model:必須透過 Service Interface
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 依據跨模組通訊規範,若需解析其他模組的 ID(例如在 `Procurement` 模組中解析 `warehouse_id`),
|
||||
> **禁止**直接 `Warehouse::find()`,必須透過 Service Interface。
|
||||
|
||||
```php
|
||||
// ✅ 正確:透過 Service Interface 取得跨模組資料
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
|
||||
->getWarehouse($data['warehouse_id']);
|
||||
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `Core` 模組的 `User`, `Role`, `Tenant` 屬於全域例外,其他模組可直接查詢。
|
||||
> 詳見 [跨模組通訊規範](file:///home/mama/projects/star-erp/.agents/skills/cross-module-communication/SKILL.md)。
|
||||
|
||||
### 2.5 完整 `tapActivity` 範例(參考 PurchaseOrder)
|
||||
|
||||
```php
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
// 🚩 轉換為陣列
|
||||
$properties = $activity->properties instanceof \Illuminate\Support\Collection
|
||||
? $activity->properties->toArray()
|
||||
: $activity->properties;
|
||||
|
||||
// 1. Snapshot 快照
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['po_number'] = $this->code;
|
||||
$snapshot['vendor_name'] = $this->vendor?->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
// 2. ID 轉名稱
|
||||
$resolver = function (&$data) {
|
||||
if (empty($data) || !is_array($data)) return;
|
||||
|
||||
// 全域例外:User 可直接查
|
||||
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
|
||||
if (isset($data[$f]) && is_numeric($data[$f])) {
|
||||
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name ?? $data[$f];
|
||||
}
|
||||
}
|
||||
// 同模組:可直接查
|
||||
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
|
||||
$data['vendor_id'] = Vendor::find($data['vendor_id'])?->name ?? $data['vendor_id'];
|
||||
}
|
||||
// 跨模組:必須透過 Service Interface
|
||||
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
|
||||
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
|
||||
->getWarehouse($data['warehouse_id']);
|
||||
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($properties['attributes'])) $resolver($properties['attributes']);
|
||||
if (isset($properties['old'])) $resolver($properties['old']);
|
||||
|
||||
// 3. 合併 activityProperties (手動傳入的 items_diff 等)
|
||||
if (!empty($this->activityProperties)) {
|
||||
$properties = array_merge($properties, $this->activityProperties);
|
||||
}
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 複雜操作的日誌合併 (Log Consolidation)
|
||||
|
||||
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
|
||||
|
||||
### 3.1 手動記錄必須自行過濾差異
|
||||
|
||||
```php
|
||||
// ✅ 正確:自行比對差異,只存變動值
|
||||
$changedAttributes = [];
|
||||
$changedOldAttributes = [];
|
||||
|
||||
foreach ($newAttributes as $key => $value) {
|
||||
if ($value != ($oldAttributes[$key] ?? null)) {
|
||||
$changedAttributes[$key] = $value;
|
||||
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($changedAttributes)) {
|
||||
activity()
|
||||
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
|
||||
->log('updated');
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `saveQuietly()` + 手動日誌 合併策略
|
||||
|
||||
```php
|
||||
DB::transaction(function () use ($doc, $items) {
|
||||
// 1. 更新品項 (記錄變動細節)
|
||||
$updatedItems = $this->getUpdatedItems($doc, $items);
|
||||
|
||||
// 2. 靜默更新單據狀態 (避免 Trait 產生冗餘日誌)
|
||||
$doc->status = 'completed';
|
||||
$doc->saveQuietly();
|
||||
|
||||
// 3. 手動觸發單一合併日誌
|
||||
activity()
|
||||
->performedOn($doc)
|
||||
->withProperties([
|
||||
'items_diff' => ['updated' => $updatedItems],
|
||||
'attributes' => ['status' => 'completed'],
|
||||
'old' => ['status' => 'counting']
|
||||
])
|
||||
->log('updated');
|
||||
});
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `saveQuietly()` 會繞過 Model Events(如自動單號產生)。
|
||||
> 若 Model 有 `creating`/`updating` 事件產生單號,需在 Service 中手動處理。
|
||||
|
||||
---
|
||||
|
||||
## 4. 後端 Controller 映射 (Subject Map)
|
||||
|
||||
新增 Model 時,必須同步在 `ActivityLogController::getSubjectMap()` 加入中文映射。
|
||||
|
||||
**位置**: `app/Modules/Core/Controllers/ActivityLogController.php`
|
||||
|
||||
```php
|
||||
private function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
// ... 新增此行
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端介面規範 (Frontend)
|
||||
|
||||
### 5.1 標籤命名規範 (Field Labels)
|
||||
|
||||
前端顯示應完全移除「ID」字眼,提供最友善的閱讀體驗。
|
||||
|
||||
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||
|
||||
```typescript
|
||||
const fieldLabels: Record<string, string> = {
|
||||
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
|
||||
created_by: '建立者', // ❌ 禁用「建立者 ID」
|
||||
completed_by: '完成者',
|
||||
status: '狀態',
|
||||
// 新增 Model 的欄位翻譯 ...
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 `nameParams` 必須在兩處同步更新
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `nameParams` 在 `LogTable.tsx` 和 `ActivityDetailDialog.tsx` 中各有一份,
|
||||
> 新增時**必須兩處同步更新**,否則會導致列表與詳情頁顯示不一致。
|
||||
|
||||
| 檔案 | 用途 |
|
||||
|---|---|
|
||||
| `resources/js/Components/ActivityLog/LogTable.tsx` | 列表頁的描述文字 |
|
||||
| `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` | 對話框標題 |
|
||||
|
||||
### 5.3 特殊結構顯示
|
||||
|
||||
* **品項異動**:前端已能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」方式呈現表格。
|
||||
* **顯示過濾邏輯**(已內建於 `ActivityDetailDialog`):
|
||||
- **Created**: 顯示初始化欄位
|
||||
- **Updated**: 僅顯示有變動的欄位 (`isChanged` 判斷)
|
||||
- **Deleted**: 顯示刪除前的完整資料
|
||||
|
||||
---
|
||||
|
||||
## 6. 開發檢核清單 (Checklist)
|
||||
|
||||
- [ ] **Model**: 是否已設定 `logOnlyDirty` + `dontSubmitEmptyLogs`?
|
||||
- [ ] **Model**: `tapActivity` 型別是否使用 `Contracts\Activity`?
|
||||
- [ ] **Model**: `tapActivity` 是否已使用 `toArray()` 處理 Collection?
|
||||
- [ ] **Model**: 是否已實作 Snapshot(關鍵識別資訊)?
|
||||
- [ ] **Model**: ID 轉名稱是否遵守跨模組規範(Core 例外,其餘需透過 Interface)?
|
||||
- [ ] **Service**: 是否使用 `saveQuietly()` 搭配手動 `activity()` 避免重複日誌?
|
||||
- [ ] **Controller**: `ActivityLogController::getSubjectMap()` 是否已新增 Model 中文映射?
|
||||
- [ ] **UI**: `fieldLabels` 是否已新增欄位中文翻譯?
|
||||
- [ ] **UI**: `nameParams` 是否已在 `LogTable` 和 `ActivityDetailDialog` 兩處同步?
|
||||
137
.agents/skills/cross-module-communication/SKILL.md
Normal file
137
.agents/skills/cross-module-communication/SKILL.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: 跨模組調用與通訊規範 (Cross-Module Communication)
|
||||
description: 規範 Laravel Modular Monolith 架構下,不同業務模組中如何彼此調用資料與邏輯,包含禁止項目、Interface 實作、與 Service 綁定規則。
|
||||
---
|
||||
|
||||
# 跨模組調用與通訊規範 (Cross-Module Communication)
|
||||
|
||||
為了確保專案的「模組化單體架構 (Modular Monolith)」的獨立性與可維護性,當遇到**需要跨越不同業務模組存取資料或調用功能**的情境時,請嚴格遵守以下規範。
|
||||
|
||||
## 🚫 絕對禁止的行為 (Strict Prohibitions)
|
||||
|
||||
* **禁止跨模組 Eloquent 關聯(例外除外)**
|
||||
* **禁止跨模組直接引入 (use) Model**
|
||||
* **禁止跨模組直接實例化 (new) Service**
|
||||
|
||||
---
|
||||
|
||||
## 🌟 允許的全域例外 (Global Exceptions)
|
||||
|
||||
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
|
||||
|
||||
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model:
|
||||
1. **`App\Modules\Core\Models\User`**
|
||||
2. **`App\Modules\Core\Models\Role`**
|
||||
3. **`App\Modules\Core\Models\Tenant`**
|
||||
|
||||
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`)**絕對不能**反過來直接 `use` 外部業務模組的 Model,仍必須透過外部模組的 Service Interface 來索取資料。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 正確的跨模組調用流程:合約與依賴反轉
|
||||
|
||||
所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。
|
||||
|
||||
### Step 1: 在被調用的模組定義合約 (Interface)
|
||||
|
||||
如果 `Inventory` 模組需要提供功能給外部使用,請在 `app/Modules/Inventory/Contracts/` 建立 Interface 檔案。
|
||||
|
||||
```php
|
||||
namespace App\Modules\Inventory\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface InventoryServiceInterface
|
||||
{
|
||||
public function getActiveWarehouses(): Collection;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 實作介面並在自己模組的 ServiceProvider 註冊
|
||||
|
||||
由 `Inventory` 模組自己的 Service 來實作上述介面。
|
||||
|
||||
```php
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class InventoryService implements InventoryServiceInterface
|
||||
{
|
||||
public function getActiveWarehouses(): Collection
|
||||
{
|
||||
return Warehouse::where('is_active', true)
|
||||
->select(['id', 'name', 'code'])
|
||||
->get();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然後進入 `app/Modules/Inventory/InventoryServiceProvider.php` 完成綁定:
|
||||
|
||||
```php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
|
||||
class InventoryServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 調用方透過依賴注入 (DI) 使用服務
|
||||
|
||||
當 `Procurement` 模組需要取得倉庫資料時,必須透過**建構子注入**或**方法注入**取得 `InventoryServiceInterface`。
|
||||
|
||||
```php
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InventoryServiceInterface $inventoryService
|
||||
) {}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$warehouses = $this->inventoryService->getActiveWarehouses();
|
||||
|
||||
return Inertia::render('Procurement/PurchaseOrder/Create', [
|
||||
'warehouses' => $warehouses
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 跨模組資料回傳的注意事項 (Data Hydration)
|
||||
|
||||
* **回傳純粹資料**:建議在 Service 中用 `with()` 載入好關聯,或者直接轉為原生的 Array 或有具體結構的 DTO,避免依賴 Lazy Loading。
|
||||
* **手動組合 (Manual Hydration)**:若某個頁面需要合併兩個模組的資料,必須在 Controller 層級呼叫兩個不同的 Service Interface 後,手動合併。
|
||||
|
||||
### 範例:手動合併資料
|
||||
```php
|
||||
// 正確示範:在各自模組取資料,並手動組裝
|
||||
$orders = $this->orderService->getOrders();
|
||||
$userIds = $orders->pluck('user_id')->unique()->toArray();
|
||||
$users = $this->coreUserService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$mergedData = $orders->map(function ($order) use ($users) {
|
||||
// 將使用者資料手動附加上去
|
||||
$order->user_name = $users->get($order->user_id)->name ?? 'Unknown';
|
||||
return $order;
|
||||
});
|
||||
```
|
||||
266
.agents/skills/e2e-testing/SKILL.md
Normal file
266
.agents/skills/e2e-testing/SKILL.md
Normal file
@@ -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`?
|
||||
51
.agents/skills/git-workflows/SKILL.md
Normal file
51
.agents/skills/git-workflows/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: Git 分支管理與開發規範 (Git Workflow)
|
||||
description: 規範開發過程中的 Git 分支架構、合併限制、環境部署流程以及提交訊息格式。
|
||||
---
|
||||
|
||||
# Git 分支管理與開發規範 (Git Workflow)
|
||||
|
||||
為了確保系統穩定性與發布紀律,所有開發者與 AI 助手必須嚴格遵守以下環境發布流程與時段限制。
|
||||
|
||||
## 1. 分支架構與環境定義
|
||||
|
||||
| 分支 | 環境 | 用途描述 | 合併來源 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`dev`** | 本機開發 | 日常開發與功能實作。 | `feature/*` |
|
||||
| **`demo`** | 測試/預佈署 | 鏡像生產環境。用於正式上線前的最終驗證。 | `dev` |
|
||||
| **`main`** | 生產環境 | 正式版本分支。僅存放透過 `demo` 驗證後的代碼。 | `demo` |
|
||||
|
||||
## 2. 發布時段與約束 (Release Window)
|
||||
|
||||
### Main 分支發布限制 (Mandatory)
|
||||
1. **強制規範**:若執行推送/合併指令時未明確包含目標分支,**嚴禁** 自行預設或推論為 `main`。我必須先詢問使用者:「請問要推送到哪一個目標分支?(dev / demo / main)」。
|
||||
2. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。
|
||||
3. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`:
|
||||
- AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。
|
||||
- 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。
|
||||
4. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。
|
||||
|
||||
## 3. 開發與修復流程 (SOP)
|
||||
|
||||
### 標準開發流程
|
||||
1. `feature/*` -> `dev` (隨時合併,主要測試點)。
|
||||
2. `dev` -> `demo` (隨時合併,進行類生產環境測試)。
|
||||
3. `demo` -> `main` (僅限允許時段進行,正式上線)。
|
||||
|
||||
### 緊急修復流程 (Hotfix)
|
||||
1. 直接從 `main` 建立 `hotfix/*` 分支進行修復。
|
||||
2. 修復完成並通過測試後合併回 `main`。
|
||||
3. **重要同步**:修復後的程式碼必須立即合併回 `demo` 與 `dev`,確保各環境修復同步。
|
||||
|
||||
## 4. 提交訊息規範 (Commit Messages)
|
||||
|
||||
提交訊息必須包含以下前綴:
|
||||
- `[FIX]`:修復 Bug。
|
||||
- `[FEAT]`:新增功能。
|
||||
- `[DOCS]`:文件更新。
|
||||
- `[STYLE]`:UI/格式調整。
|
||||
- `[REFACTOR]`:重構。
|
||||
|
||||
---
|
||||
> [!IMPORTANT]
|
||||
> 身為 AI 助手 (Antigravity),我會監控合併對象與當前時間。若您的命令涉及合併至 `main` 且不在允許時段內,我會優先進行安全提醒。
|
||||
206
.agents/skills/permission-management/SKILL.md
Normal file
206
.agents/skills/permission-management/SKILL.md
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
name: 權限管理與實作規範
|
||||
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||
---
|
||||
|
||||
# 權限管理與實作規範
|
||||
|
||||
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定義權限 (Backend Seeder)
|
||||
|
||||
所有權限皆定義於 `database/seeders/PermissionSeeder.php`。
|
||||
|
||||
### 步驟:
|
||||
|
||||
1. 開啟 `database/seeders/PermissionSeeder.php`。
|
||||
2. 在 `$permissions` 關聯陣列中新增功能對應的權限。
|
||||
* **命名慣例**:`{resource}.{action}`(例如:`system.view_logs`, `products.create`)
|
||||
* **格式**:`'權限字串' => '中文動作名稱'`
|
||||
* 常用動作:`view`, `create`, `edit`, `delete`, `approve`, `cancel`, `export`
|
||||
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 1. 新增權限(注意:是 key => value 格式)
|
||||
$permissions = [
|
||||
// ... 現有權限
|
||||
'utility_fees.view' => '檢視',
|
||||
'utility_fees.create' => '建立',
|
||||
'utility_fees.edit' => '編輯',
|
||||
'utility_fees.delete' => '刪除',
|
||||
];
|
||||
|
||||
// 2. 分配給角色
|
||||
$admin->givePermissionTo([
|
||||
// ... 現有權限
|
||||
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
|
||||
]);
|
||||
```
|
||||
|
||||
### 現有角色定義:
|
||||
|
||||
| 角色 | 說明 | 權限範圍 |
|
||||
|---|---|---|
|
||||
| `super-admin` | 系統管理員 | 自動擁有所有權限(`Permission::all()`) |
|
||||
| `admin` | 一般管理員 | 大部分權限(除角色管理外) |
|
||||
| `warehouse-manager` | 倉庫管理員 | 庫存、盤點、調撥、進貨、門市叫貨 |
|
||||
| `purchaser` | 採購人員 | 商品檢視、採購單、退貨、供應商、進貨 |
|
||||
| `viewer` | 檢視人員 | 僅限各模組的 `.view` 權限 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 套用資料庫變更 (Multi-tenancy)
|
||||
|
||||
修改 Seeder 後,必須在**中央與所有租戶**同步執行。
|
||||
|
||||
```bash
|
||||
# 對所有租戶執行 Seeder
|
||||
./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 僅執行 `db:seed` 只會更新中央資料庫。務必使用 `tenants:seed` 確保所有租戶同步。
|
||||
|
||||
---
|
||||
|
||||
## 3. 路由保護 (Backend Middleware)
|
||||
|
||||
路由保護定義在各模組自己的 `app/Modules/{ModuleName}/Routes/web.php` 中。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 路由檔在各模組內(如 `app/Modules/Finance/Routes/web.php`),**不是**全域的 `routes/web.php`。
|
||||
|
||||
### 範例:
|
||||
|
||||
```php
|
||||
// 單一權限保護
|
||||
Route::middleware('permission:utility_fees.view')->group(function () {
|
||||
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
|
||||
Route::get('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'show'])->name('utility-fees.show');
|
||||
});
|
||||
|
||||
// 巢狀權限群組
|
||||
Route::middleware('permission:utility_fees.create')->group(function () {
|
||||
Route::get('/utility-fees/create', [UtilityFeeController::class, 'create'])->name('utility-fees.create');
|
||||
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
|
||||
});
|
||||
|
||||
// 單行 middleware
|
||||
Route::delete('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'destroy'])
|
||||
->middleware('permission:utility_fees.delete')
|
||||
->name('utility-fees.destroy');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置權限群組名稱 (Backend UI Config)
|
||||
|
||||
為了讓新權限在「角色與權限」管理介面中正確分組並顯示中文標題,需修改 Controller。
|
||||
|
||||
**位置**: `app/Modules/Core/Controllers/RoleController.php` → `getGroupedPermissions()`
|
||||
|
||||
```php
|
||||
$groupDefinitions = [
|
||||
'products' => '商品資料管理',
|
||||
'warehouses' => '倉庫管理',
|
||||
'inventory' => '庫存資料管理',
|
||||
// ...
|
||||
'utility_fees' => '公共事業費管理', // ✅ 新增此行
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 未加入 `$groupDefinitions` 的權限群組仍會顯示,但標題會以原始 key(英文)呈現。
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端權限判斷 (React)
|
||||
|
||||
### 5.1 方式一:`usePermission` Hook(在邏輯中判斷)
|
||||
|
||||
**位置**: `resources/js/hooks/usePermission.ts`
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
|
||||
export default function ProductIndex() {
|
||||
const { can, canAny, isSuperAdmin } = usePermission();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{can('products.create') && <Button>新增商品</Button>}
|
||||
{canAny(['products.edit', 'products.delete']) && <ManageDropdown />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Hook 完整介面:
|
||||
|
||||
| 方法 | 說明 |
|
||||
|---|---|
|
||||
| `can(permission)` | 檢查是否擁有**指定**權限 |
|
||||
| `canAny(permissions[])` | 檢查是否擁有**任一**權限 |
|
||||
| `canAll(permissions[])` | 檢查是否擁有**所有**權限 |
|
||||
| `hasRole(role)` | 檢查是否擁有**指定**角色 |
|
||||
| `hasAnyRole(roles[])` | 檢查是否擁有**任一**角色 |
|
||||
| `hasAllRoles(roles[])` | 檢查是否擁有**所有**角色 |
|
||||
| `isSuperAdmin()` | 是否為超級管理員 |
|
||||
|
||||
> 所有方法對 `super-admin` 角色自動回傳 `true`。
|
||||
|
||||
### 5.2 方式二:`<Can>` / `<HasRole>` / `<CanAll>` 元件(在 JSX 中包裹)
|
||||
|
||||
**位置**: `resources/js/Components/Permission/Can.tsx`
|
||||
|
||||
```tsx
|
||||
import { Can, HasRole, CanAll } from '@/Components/Permission/Can';
|
||||
|
||||
// 單一權限
|
||||
<Can permission="products.create">
|
||||
<Button>新增商品</Button>
|
||||
</Can>
|
||||
|
||||
// 任一權限(OR 邏輯)
|
||||
<Can permission={['products.edit', 'products.delete']}>
|
||||
<ManageDropdown />
|
||||
</Can>
|
||||
|
||||
// 所有權限都必須有(AND 邏輯)
|
||||
<CanAll permissions={['products.edit', 'products.delete']}>
|
||||
<Button>完整管理</Button>
|
||||
</CanAll>
|
||||
|
||||
// 角色判斷
|
||||
<HasRole role="admin">
|
||||
<Link href="/admin">管理後台</Link>
|
||||
</HasRole>
|
||||
|
||||
// Fallback 支援
|
||||
<Can permission="products.delete" fallback={<span className="text-gray-400">無權限</span>}>
|
||||
<Button variant="destructive">刪除</Button>
|
||||
</Can>
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> UI 規範要求:所有可操作按鈕(新增、編輯、刪除)**必須**包裹 `<Can>` 元件或使用 `can()` 判斷。
|
||||
> 詳見 [UI 統一規範](file:///home/mama/projects/star-erp/.agents/skills/ui-consistency/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 開發檢核清單 (Checklist)
|
||||
|
||||
### 後端
|
||||
- [ ] `PermissionSeeder.php` 已新增權限字串(`'key' => '中文動作名稱'` 格式)。
|
||||
- [ ] `PermissionSeeder.php` 已將新權限分配給 `admin` 及其他適用角色。
|
||||
- [ ] 已執行 `./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder` 同步所有租戶。
|
||||
- [ ] `RoleController.php` 的 `$groupDefinitions` 已新增權限群組中文名稱。
|
||||
- [ ] 模組路由 (`app/Modules/{ModuleName}/Routes/web.php`) 已加上 `middleware('permission:...')` 保護。
|
||||
|
||||
### 前端
|
||||
- [ ] 頁面按鈕已使用 `usePermission` Hook 或 `<Can>` 元件進行權限控制。
|
||||
- [ ] 所有可操作按鈕都包裹於權限判斷中(符合 UI 統一規範)。
|
||||
1058
.agents/skills/ui-consistency/SKILL.md
Normal file
1058
.agents/skills/ui-consistency/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
35
.agents/workflows/now-push.md
Normal file
35
.agents/workflows/now-push.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
description: 將目前的變更提交並推送至指定的遠端分支 (遵守專案規範)
|
||||
---
|
||||
|
||||
# 快速推送工作流 (now-push)
|
||||
|
||||
本工作流旨在規範化 Git 提交與推送流程,確保符合專案的開發規範 (繁體中文、規範前綴) 與發布紀律 (Release Window)。
|
||||
|
||||
## 執行步驟
|
||||
|
||||
1. **讀取規範 (Mandatory)**
|
||||
在執行任何 Git 操作前,**必須** 先讀取 Git 分支管理與開發規範:
|
||||
`view_file` -> [Git SKILL.md](file:///home/mama/projects/star-erp/.agents/skills/git-workflows/SKILL.md)
|
||||
|
||||
2. **檢查與準備**
|
||||
- 執行 `git status` 檢查目前工作目錄。
|
||||
- 根據 **SKILL.md** 的規範撰寫繁體中文提交訊息。
|
||||
|
||||
3. **目標分支安全檢查**
|
||||
- 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
|
||||
- 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`。
|
||||
- **【最嚴格限制】**:`main` 分支的程式碼**只能**, **必須**從 `demo` 分支合併而來。絕對禁止將 `dev` (或 `feature/*`) 直接合併進 `main`。
|
||||
|
||||
4. **執行推送 (Push) 與嚴格合併鏈路**
|
||||
- **若目標為 `dev`**:直接 `git push origin [目前分支]:dev` 或 commit 後 merge 到 dev。
|
||||
- **若目標為 `demo`**:必須先確保變更已在 `dev` 且無衝突,然後 `git checkout demo && git merge dev && git push origin demo`。
|
||||
- **若目標為 `main`**:
|
||||
必須確保變更已經依照順序通過前置環境,嚴格執行以下流程(缺一不可):
|
||||
1. `git checkout dev && git merge [目前分支] && git push origin dev`
|
||||
2. `git checkout demo && git merge dev && git push origin demo`
|
||||
3. `git checkout main && git merge demo && git push origin main`
|
||||
*(就算遭遇衝突,也必須在對應的分支上解衝突,絕對不可略過 `demo` 直接 `dev -> main`)*
|
||||
|
||||
5. **後續同步 (針對 Hotfix)**
|
||||
- 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」:若有從 main 開出來的 hotfix 分支直接併回 main 的例外情況(需使用者明確指示),**必須**同步將 main 分支 merge 回 `demo` 與 `dev` 分支,維持全環境版本一致。
|
||||
12
.env.example
12
.env.example
@@ -1,8 +1,14 @@
|
||||
APP_NAME=KooriERP
|
||||
APP_NAME=StarERP
|
||||
COMPOSE_PROJECT_NAME=star-erp
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=v1.0.0
|
||||
|
||||
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
|
||||
CENTRAL_DOMAINS=localhost,127.0.0.1
|
||||
TENANT_DEFAULT_DOMAIN=star-erp.test
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -23,7 +29,7 @@ LOG_LEVEL=debug
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=koori_erp
|
||||
DB_DATABASE=star_erp
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
FORWARD_DB_PORT=3307
|
||||
@@ -38,7 +44,7 @@ BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=redis
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
101
.gitea/workflows/deploy-demo.yaml
Normal file
101
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,101 @@
|
||||
name: ERP-Deploy-Demo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- demo
|
||||
|
||||
jobs:
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.taiwan-star.com.tw
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Demo
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||
chmod 600 ~/.ssh/id_rsa_demo
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='vendor' \
|
||||
--exclude='storage' \
|
||||
--exclude='.env' \
|
||||
--exclude='public/build' \
|
||||
-e "ssh -p 2227 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||
./ root@220.132.7.82:/var/www/star-erp-demo/
|
||||
rm ~/.ssh/id_rsa_demo
|
||||
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp-demo
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp-demo
|
||||
chown -R 1000:1000 .
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||
|
||||
- name: Step 4 - Composer & NPM Build
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
npm install &&
|
||||
npm run build &&
|
||||
rm -f public/hot &&
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan tenants:migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
|
||||
php artisan tenants:run db:seed --option=\"class=SystemSettingSeeder\" --option=\"force=true\" &&
|
||||
php artisan permission:cache-reset &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
"
|
||||
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
94
.gitea/workflows/deploy-prod.yaml
Normal file
94
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
name: ERP-Deploy-Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Production
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_rsa_prod
|
||||
chmod 600 ~/.ssh/id_rsa_prod
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--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@220.132.7.82:/var/www/star-erp/
|
||||
rm ~/.ssh/id_rsa_prod
|
||||
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild_prod
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2224
|
||||
username: root
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-erp
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2224
|
||||
username: root
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
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|compose\.prod\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||
|
||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||
composer install --no-dev --optimize-autoloader &&
|
||||
npm install &&
|
||||
npm run build &&
|
||||
rm -f public/hot
|
||||
|
||||
php artisan storage:link &&
|
||||
php artisan migrate --force &&
|
||||
php artisan tenants:migrate --force &&
|
||||
php artisan db:seed --force &&
|
||||
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
|
||||
php artisan tenants:run db:seed --option=\"class=SystemSettingSeeder\" --option=\"force=true\" &&
|
||||
php artisan permission:cache-reset &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache
|
||||
"
|
||||
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Koori-ERP-Demo-Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
sync-update:
|
||||
runs-on: demo-server
|
||||
steps:
|
||||
- name: 1. Checkout New Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: http://192.168.0.103:3000
|
||||
repository: ${{ gitea.repository }}
|
||||
|
||||
|
||||
- name: 2. Sync Files to Running Container
|
||||
run: |
|
||||
# B. 執行複製
|
||||
cp .env.example .env
|
||||
|
||||
sed -i "s|^APP_KEY=.*|APP_KEY=${{ secrets.APP_KEY }}|g" .env
|
||||
|
||||
docker compose up -d --build --force-recreate --wait
|
||||
# 這一步最關鍵!它把剛抓下來的新 Code 塞進去「正在跑」的容器
|
||||
# 它不會刪掉你的 .env,因為它是用 tar 覆蓋進去
|
||||
tar -cf - . | docker exec -i koori-erp-laravel.test-1 tar -xf - -C /var/www/html
|
||||
|
||||
|
||||
- name: 3. Refresh Backend (Only if needed)
|
||||
run: |
|
||||
# 執行 Composer (確保你有加新套件的話會抓到)
|
||||
docker exec -w /var/www/html koori-erp-laravel.test-1 composer install --optimize-autoloader
|
||||
# 同步資料庫欄位 (如果你有改 Migration 的話)
|
||||
docker exec koori-erp-laravel.test-1 php artisan migrate --force
|
||||
|
||||
- name: 4. Build New React Assets
|
||||
run: |
|
||||
# 既然你改了前端,就要在容器內重新跑一次打包
|
||||
docker exec -w /var/www/html koori-erp-laravel.test-1 npm install
|
||||
docker exec -w /var/www/html koori-erp-laravel.test-1 npm run build
|
||||
- name: 5. Final Permission Fix
|
||||
run: |
|
||||
# 統一修正權限
|
||||
docker exec koori-erp-laravel.test-1 chown -R 1000:1000 /var/www/html
|
||||
docker exec koori-erp-laravel.test-1 chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
- name: 6. Clear Old Cache
|
||||
run: |
|
||||
# 讓 Laravel 重新讀取最新的路由與設定
|
||||
docker exec koori-erp-laravel.test-1 php artisan view:clear
|
||||
docker exec koori-erp-laravel.test-1 php artisan config:clear
|
||||
docker exec koori-erp-laravel.test-1 php artisan route:clear
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -18,7 +18,28 @@
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/tenant*
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
酒水客戶導入規劃.md
|
||||
智慧補貨系統分析報告.md
|
||||
|
||||
/docs/pptx_build
|
||||
/docs/presentation
|
||||
docs/Monthly_Report_2026_01.pptx
|
||||
docs/f6_1770350984272.xlsx
|
||||
公共事業費-描述.md
|
||||
.gitignore
|
||||
BOM表自動計算成本.md
|
||||
公共事業費-類別維護.md
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
e2e/screenshots/
|
||||
|
||||
211
README.md
211
README.md
@@ -1,81 +1,182 @@
|
||||
# Koori ERP
|
||||
# Star ERP (Koori ERP)
|
||||
|
||||
本專案是一個基於 Laravel 12, Inertia.js (React) 與 Tailwind CSS 開發的 ERP 系統。
|
||||
Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind CSS** 構建的現代化多租戶 ERP 系統。
|
||||
本專案專為高效能、SaaS 架構設計,並預設配置了完整的 Docker 開發環境。
|
||||
|
||||
## 開發環境需求
|
||||
## 🌟 專案架構
|
||||
|
||||
- **WSL2** (Windows 建議環境)
|
||||
- **Docker Desktop** 或 **Docker Engine**
|
||||
- **PHP 8.5+** (本地端若需執行基礎 composer 指令,或直接使用 Sail 容器)
|
||||
- **Node.js 20+**
|
||||
- **核心框架**: Laravel 12 (PHP 8.5)
|
||||
- **多租戶引擎**: stancl/tenancy (Single Database per Tenant)
|
||||
- **前端架構**: React 18, Inertia.js (單體式/Monolith)
|
||||
- **UI 框架**: Tailwind CSS
|
||||
- **基礎設施**: Docker (Laravel Sail), Nginx Reverse Proxy, MySQL 8.0, Redis
|
||||
|
||||
## 啟動步驟
|
||||
## 📂 系統功能詳細說明
|
||||
|
||||
本專案使用 [Laravel Sail](https://laravel.com/docs/12.x/sail) 作為 Docker 開發環境。
|
||||
### 🌳 預計系統功能架構樹 (含 2.0 升級規劃)
|
||||
```text
|
||||
Star ERP
|
||||
├── 🏠 儀表板 (Dashboard)
|
||||
│ ├── 📊 數據看板 (原有)
|
||||
│ ├── 🔔 營運警示 (原有)
|
||||
│ ├── ✨ 銷售熱力圖 (新)
|
||||
│ ├── ✨ 庫存效期預警 (新)
|
||||
│ └── ✨ 待出貨監控 (新)
|
||||
├── ✨ 🤝 銷售與全通路 (Sales & CRM) 【New】
|
||||
│ ├── ✨ 全通路訂單整合
|
||||
│ ├── ✨ 客戶管理 (CRM)
|
||||
│ └── ✨ 促銷活動
|
||||
├── 📦 商品與庫存管理
|
||||
│ ├── 📄 商品資料 (原有)
|
||||
│ ├── 🏢 倉庫管理 (原有)
|
||||
│ ├── 🚚 內調撥 (原有)
|
||||
│ ├── ✨ 屬性管理 (過敏原/成分)
|
||||
│ ├── ✨ 效期監控 (FEFO)
|
||||
│ └── ✨ 智慧補貨建議 (AI)
|
||||
├── ✨ 🚚 智慧物流 (Logistics) 【New】
|
||||
│ ├── ✨ 路徑規劃
|
||||
│ └── ✨ 裝車單管理
|
||||
├── 🏭 生產與品質管理
|
||||
│ ├── 📝 生產工單 (原有)
|
||||
│ ├── 🧪 原料耗用 (原有)
|
||||
│ ├── ✨ 配方管理 (Recipe)
|
||||
│ ├── ✨ 品質檢驗 (QC)
|
||||
│ └── ✨ 雙向溯源 (原料<->成品)
|
||||
├── 🛒 採購與廠商
|
||||
│ ├── 👥 廠商資料 (原有)
|
||||
│ ├── 📝 採購單 (原有)
|
||||
│ └── ✨ 供應商評鑑 (新)
|
||||
├── 💰 財務管理
|
||||
│ ├── 🧾 公共事業費 (原有)
|
||||
│ ├── ✨ 應收/應付帳款 (AR/AP)
|
||||
│ └── ✨ 成本精算 (料工費)
|
||||
├── 📊 報表管理
|
||||
│ └── 📑 會計報表 (原有)
|
||||
└── ⚙️ 系統管理 (原有)
|
||||
├── 👤 使用者管理
|
||||
├── 🛡️ 角色與權限
|
||||
└── 📜 操作紀錄
|
||||
```
|
||||
|
||||
### 1. 安裝依賴 (初次啟動)
|
||||
---
|
||||
|
||||
建立目錄:mkdir 檔案名稱 && cd 檔案名稱
|
||||
#### 1. 🏠 儀表板 (Dashboard)
|
||||
- **數據看板**:顯示商品總數、供應商數、活躍倉庫數等。
|
||||
- **營運警示**:低庫存商品與待辦事項警示。
|
||||
- **✨ 強化功能**:銷售熱力圖、庫存效期預警、待出貨監控。
|
||||
|
||||
抓取代碼:git clone http://git網址/帳號/專案.git .
|
||||
#### 2. ✨ 🤝 銷售與全通路 (Sales & CRM)
|
||||
- **全通路訂單**:整合 POS、品牌電商、智慧販賣機訂單。
|
||||
- **客戶管理 (CRM)**:會員資料庫、消費歷史與等級。
|
||||
- **促銷活動**:滿額折、買一送一、組合價等折扣引擎。
|
||||
|
||||
如果您是第一次 clone 專案,請先安裝 PHP 與 JS 依賴:
|
||||
#### 3. 📦 商品與庫存管理
|
||||
- **商品資料**:品名、規格、多單位換算。
|
||||
- **倉庫管理**:多站點庫存監控、銷售設定。
|
||||
- **內調撥**:倉庫間庫存轉移。
|
||||
- **✨ 強化功能**:過敏原/成分管理、**FEFO (先到期先出)** 效期監控、AI 智慧補貨建議。
|
||||
|
||||
#### 4. 🏭 生產與品質管理
|
||||
- **生產工單**:排程管理、生產入庫。
|
||||
- **✨ 強化功能**:配方管理 (Recipe V.C.)、QC 檢驗流程 (IQC/IPQC/FQC)、**雙向溯源** (原料 <-> 成品)。
|
||||
|
||||
#### 5. 🛒 採購與廠商
|
||||
- **採購單**:詢價、下單、收貨與驗收流程。
|
||||
- **✨ 強化功能**:供應商評鑑系統。
|
||||
|
||||
#### 6. 💰 財務管理
|
||||
- **公共事業費**:水電氣網等固定支出。
|
||||
- **✨ 強化功能**:應收/應付帳款 (AR/AP) 管理、**成本精算** (料工費分攤)。
|
||||
|
||||
#### 7. ⚙️ 系統管理
|
||||
- **使用者與權限**:RBAC 細緻權限控管。
|
||||
- **操作紀錄**:全系統關鍵行為軌跡 (Audit Log)。
|
||||
|
||||
## 🚀 快速開始
|
||||
|
||||
### 1. 環境準備
|
||||
請確保您的開發環境已安裝:
|
||||
- Docker Desktop 或 Docker Engine
|
||||
- Git
|
||||
- WSL2 (Windows 用戶建議)
|
||||
|
||||
### 2. 初始化專案
|
||||
|
||||
```bash
|
||||
# 1. 下載專案
|
||||
git clone <repository_url> star-erp
|
||||
cd star-erp
|
||||
|
||||
# 初始化 .env 檔案
|
||||
|
||||
# 2. 設定環境變數
|
||||
cp .env.example .env
|
||||
# 請檢查 .env 內容,本機開發預設配置:
|
||||
# APP_PORT=8080 (總後台)
|
||||
# DEMO_TENANT_PORT=8081 (租戶測試)
|
||||
# VITE_PORT=5174
|
||||
|
||||
```
|
||||
|
||||
### 2. 啟動 Docker 容器
|
||||
|
||||
在專案根目錄執行:
|
||||
|
||||
```bash
|
||||
# 背景執行容器
|
||||
# 3. 啟動容器
|
||||
docker compose up -d --build
|
||||
|
||||
docker exec -it koori-erp-laravel.test-1 composer install
|
||||
|
||||
# 生成 App Key
|
||||
docker exec -it koori-erp-laravel.test-1 php artisan key:generate
|
||||
```
|
||||
|
||||
### 3. 資料庫遷移與初始化
|
||||
### 3. 安裝依賴與初始化
|
||||
|
||||
```bash
|
||||
# (選填) 如果有種子資料
|
||||
docker exec -it koori-erp-laravel.test-1 php artisan migrate --seed
|
||||
# 安裝 PHP 依賴
|
||||
docker exec -it star-erp-laravel composer install
|
||||
|
||||
# 生成 Application Key
|
||||
docker exec -it star-erp-laravel php artisan key:generate
|
||||
|
||||
# 執行資料庫遷移與種子資料 (建立基礎表格)
|
||||
docker exec -it star-erp-laravel php artisan migrate --seed
|
||||
|
||||
# 安裝與編譯前端資源
|
||||
docker exec -it star-erp-laravel npm install
|
||||
docker exec -it star-erp-laravel npm run dev
|
||||
```
|
||||
|
||||
### 4. 啟動前端開發伺服器 (Vite)
|
||||
## 🌐 服務訪問 (開發與 Demo 模式)
|
||||
|
||||
本專案使用獨立的 Nginx 容器 (`star-erp-proxy`) 進行反向代理,以模擬多租戶環境的分流。
|
||||
|
||||
| 服務 | URL | 說明 |
|
||||
| --- | --- | --- |
|
||||
| **總後台 (Landlord)** | `http://localhost:8080` | 中央管理介面,用於新增與管理租戶 |
|
||||
| **租戶演示 (Demo)** | `http://localhost:8081` | 模擬租戶環境,預設存取 `koori` 租戶 |
|
||||
| **Vite HMR** | `http://localhost:5174` | 前端開發熱更新服務 |
|
||||
|
||||
> **開發小撇步**:為了方便測試,本機與 Demo 環境啟用了 `DEMO_TENANT_PORT=8081` 功能,允許透過端口直接識別租戶,無需修改 hosts 檔案。
|
||||
|
||||
## 🏢 正式環境運作流程
|
||||
|
||||
在正式環境 (Production) 下,系統採用標準的 **域名識別 (Domain Identification)** 模式:
|
||||
|
||||
1. **進入總後台**:透過中央域名登入 (如 `admin.star-erp.com`)。
|
||||
2. **新增租戶**:在總後台建立新租戶 (例如 ID: `client-a`),並綁定專屬域名 (如 `erp.client-a.com`)。
|
||||
3. **DNS 設定**:在 DNS 服務商將該租戶域名 (CNAME 或 A 紀錄) 指向伺服器 IP。
|
||||
4. **訪問**:使用者直接瀏覽 `http://erp.client-a.com`,系統會自動切換至該租戶的專屬資料庫。
|
||||
|
||||
## 🛠 常用指令
|
||||
|
||||
```bash
|
||||
docker exec -it koori-erp-laravel.test-1 npm install
|
||||
docker exec -it koori-erp-laravel.test-1 npm run build
|
||||
# 進入 Laravel 容器 Shell
|
||||
docker exec -it star-erp-laravel bash
|
||||
|
||||
# 清除快取 (Config/Route/View) - 修改 .env 後建議執行
|
||||
docker exec -it star-erp-laravel php artisan optimize:clear
|
||||
|
||||
# 執行 Tinker (互動式 Shell)
|
||||
docker exec -it star-erp-laravel php artisan tinker
|
||||
|
||||
# 停止容器
|
||||
docker compose down
|
||||
```
|
||||
## 🧪 開發規範
|
||||
|
||||
啟動後,您可以透過以下連結瀏覽專案:
|
||||
- **後台網址**: [http://localhost](http://localhost)
|
||||
- **Vite 伺服器**: [http://localhost:5174](http://localhost:5174)
|
||||
|
||||
## 常用 Sail 指令
|
||||
|
||||
- **停止服務**: `./vendor/bin/sail stop`
|
||||
- **執行 Artisan 指令**: `./vendor/bin/sail artisan ...`
|
||||
- **執行 Composer 指令**: `./vendor/bin/sail composer ...`
|
||||
- **執行測試**: `./vendor/bin/sail test`
|
||||
|
||||
## 技術棧
|
||||
|
||||
- **Backend**: Laravel 12
|
||||
- **Frontend**: React (Functional Components) via Inertia.js
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Database**: MySQL 8.0
|
||||
- **Cache/Session**: Redis
|
||||
|
||||
## 開發規範
|
||||
|
||||
請參考專案內的開發文件或 AI 指導規則,確保 UI/UX 元件與後端邏輯符合專案架構。
|
||||
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
|
||||
- **前端**: React Functional Components + Hooks。UI 元件位於 `resources/js/Components`。
|
||||
- **樣式**: 全面使用 Tailwind CSS,避免手寫 CSS。
|
||||
- **多租戶**:
|
||||
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
||||
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
||||
|
||||
131
app/Console/Commands/MigrateToTenant.php
Normal file
131
app/Console/Commands/MigrateToTenant.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 將現有資料遷移到租戶資料庫
|
||||
*
|
||||
* 此指令用於初次設定多租戶時,將現有的 ERP 資料遷移到第一個租戶
|
||||
*/
|
||||
class MigrateToTenant extends Command
|
||||
{
|
||||
protected $signature = 'tenancy:migrate-data {tenant_id} {--dry-run : 只顯示會遷移的表,不實際執行}';
|
||||
protected $description = '將現有 central DB 資料遷移到指定租戶資料庫';
|
||||
|
||||
/**
|
||||
* 需要遷移的表 (依賴順序排列)
|
||||
*/
|
||||
protected array $tablesToMigrate = [
|
||||
'users',
|
||||
'password_reset_tokens',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
'categories',
|
||||
'units',
|
||||
'vendors',
|
||||
'products',
|
||||
'product_vendor',
|
||||
'warehouses',
|
||||
'inventories',
|
||||
'inventory_transactions',
|
||||
'purchase_orders',
|
||||
'purchase_order_items',
|
||||
'permissions',
|
||||
'roles',
|
||||
'model_has_permissions',
|
||||
'model_has_roles',
|
||||
'role_has_permissions',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = $this->argument('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
// 檢查租戶是否存在
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if (!$tenant) {
|
||||
$this->error("租戶 '{$tenantId}' 不存在!請先建立租戶。");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("開始遷移資料到租戶: {$tenantId}");
|
||||
$this->info("租戶資料庫: tenant{$tenantId}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('⚠️ Dry Run 模式 - 不會實際遷移資料');
|
||||
}
|
||||
|
||||
// 取得 central 資料庫連線
|
||||
$centralConnection = config('database.default');
|
||||
$tenantDbName = 'tenant' . $tenantId;
|
||||
|
||||
foreach ($this->tablesToMigrate as $table) {
|
||||
// 檢查表是否存在於 central
|
||||
if (!Schema::connection($centralConnection)->hasTable($table)) {
|
||||
$this->line(" ⏭️ 跳過 {$table} (表不存在)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 計算資料筆數
|
||||
$count = DB::connection($centralConnection)->table($table)->count();
|
||||
if ($count === 0) {
|
||||
$this->line(" ⏭️ 跳過 {$table} (無資料)");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info(" 📋 {$table}: {$count} 筆資料");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 實際遷移資料
|
||||
$this->info(" 🔄 遷移 {$table}: {$count} 筆資料...");
|
||||
|
||||
try {
|
||||
// 使用租戶上下文執行
|
||||
$tenant->run(function () use ($centralConnection, $table) {
|
||||
// 取得 central 資料
|
||||
$data = DB::connection($centralConnection)->table($table)->get();
|
||||
|
||||
if ($data->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 關閉外鍵檢查
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
|
||||
// 清空目標表
|
||||
DB::table($table)->truncate();
|
||||
|
||||
// 分批插入 (每批 100 筆)
|
||||
foreach ($data->chunk(100) as $chunk) {
|
||||
DB::table($table)->insert($chunk->map(fn($item) => (array) $item)->toArray());
|
||||
}
|
||||
|
||||
// 恢復外鍵檢查
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
});
|
||||
|
||||
$this->info(" ✅ {$table} 遷移完成");
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ {$table} 遷移失敗: " . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('🎉 資料遷移完成!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
112
app/Console/Commands/NotifyUtilityFeeStatus.php
Normal file
112
app/Console/Commands/NotifyUtilityFeeStatus.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class NotifyUtilityFeeStatus extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'finance:notify-utility-fees';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '檢查公共事業費狀態並寄送 Email 通知管理員';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info("正在掃描公共事業費狀態...");
|
||||
|
||||
// 1. 更新逾期狀態 (pending -> overdue)
|
||||
\App\Modules\Finance\Models\UtilityFee::where('payment_status', \App\Modules\Finance\Models\UtilityFee::STATUS_PENDING)
|
||||
->whereNotNull('due_date')
|
||||
->where('due_date', '<', now()->startOfDay())
|
||||
->update(['payment_status' => \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE]);
|
||||
|
||||
// 2. 獲取可能需要處理的單據 (pending 或 overdue)
|
||||
$feesToCheck = \App\Modules\Finance\Models\UtilityFee::whereIn('payment_status', [
|
||||
\App\Modules\Finance\Models\UtilityFee::STATUS_PENDING,
|
||||
\App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE
|
||||
])
|
||||
->whereNotNull('due_date')
|
||||
->orderBy('due_date', 'asc')
|
||||
->get();
|
||||
|
||||
if ($feesToCheck->isEmpty()) {
|
||||
$this->info("目前沒有未繳納的公共事業費。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 根據業務規則過濾出今天「真正」需要發信的單據
|
||||
$today = now()->startOfDay();
|
||||
$unpaidFees = $feesToCheck->filter(function ($fee) use ($today) {
|
||||
$dueDate = \Illuminate\Support\Carbon::parse($fee->due_date)->startOfDay();
|
||||
$diffInDays = $today->diffInDays($dueDate, false);
|
||||
|
||||
// 如果已經逾期 (overdue),每天都要發信
|
||||
if ($fee->payment_status === \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果是待繳納 (pending),僅在特定天數發信
|
||||
// 規則:到期前 7 天、3 天、當天 (0 天)
|
||||
return in_array($diffInDays, [7, 3, 0]);
|
||||
});
|
||||
|
||||
if ($unpaidFees->isEmpty()) {
|
||||
$this->info("今日無符合發信條件的公共事業費提醒。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. 讀取系統設定
|
||||
$senderEmail = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_email');
|
||||
$senderPassword = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_password');
|
||||
$recipientEmailsStr = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_recipient_emails');
|
||||
|
||||
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
|
||||
$this->warn("系統設定中缺乏完整的 Email 通知參數,跳過寄送通知。請至「系統設定」->「通知設定」完善資料。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. 動態覆寫應用程式名稱與 SMTP Config
|
||||
$tenantName = tenant('name') ?? config('app.name');
|
||||
config([
|
||||
'app.name' => $tenantName,
|
||||
'mail.mailers.smtp.username' => $senderEmail,
|
||||
'mail.mailers.smtp.password' => $senderPassword,
|
||||
'mail.from.address' => $senderEmail,
|
||||
'mail.from.name' => $tenantName . ' (系統通知)'
|
||||
]);
|
||||
|
||||
// 清理原先可能的 Mailer 實例,確保使用新的 Config
|
||||
\Illuminate\Support\Facades\Mail::purge();
|
||||
|
||||
// 5. 解析收件者並寄送 Email
|
||||
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
|
||||
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
|
||||
|
||||
if (empty($validRecipients)) {
|
||||
$this->warn("無效的收件者 Email 格式,跳過寄送通知。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\PaymentReminderMail($unpaidFees));
|
||||
$this->info("通知郵件已成功寄送至: " . implode(', ', $validRecipients));
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Email 寄送失敗: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
25
app/Enums/WarehouseType.php
Normal file
25
app/Enums/WarehouseType.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum WarehouseType: string
|
||||
{
|
||||
case STANDARD = 'standard'; // 標準倉/總倉
|
||||
case PRODUCTION = 'production'; // 生產倉/廚房
|
||||
case RETAIL = 'retail'; // 門市倉/前台
|
||||
case VENDING = 'vending'; // 販賣機倉/IoT
|
||||
case TRANSIT = 'transit'; // 在途倉/移動倉
|
||||
case QUARANTINE = 'quarantine'; // 瑕疵倉/報廢倉
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::STANDARD => '標準倉 (總倉)',
|
||||
self::PRODUCTION => '生產倉 (廚房/加工)',
|
||||
self::RETAIL => '門市倉 (前台销售)',
|
||||
self::VENDING => '販賣機 (IoT設備)',
|
||||
self::TRANSIT => '在途倉 (物流車)',
|
||||
self::QUARANTINE => '瑕疵倉 (報廢/檢驗)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
public function index(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
|
||||
{
|
||||
$warehouse->load([
|
||||
'inventories.product.category',
|
||||
'inventories.lastIncomingTransaction',
|
||||
'inventories.lastOutgoingTransaction'
|
||||
]);
|
||||
$allProducts = \App\Models\Product::with('category')->get();
|
||||
|
||||
// 1. 準備 availableProducts
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id, // Frontend expects string
|
||||
'name' => $product->name,
|
||||
'type' => $product->category ? $product->category->name : '其他', // 暫時用 Category Name 當 Type
|
||||
];
|
||||
});
|
||||
|
||||
// 2. 準備 inventories (模擬批號)
|
||||
// 2. 準備 inventories
|
||||
// 資料庫結構為 (warehouse_id, product_id) 唯一,故為扁平列表
|
||||
$inventories = $warehouse->inventories->map(function ($inv) {
|
||||
return [
|
||||
'id' => (string) $inv->id,
|
||||
'warehouseId' => (string) $inv->warehouse_id,
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product->name,
|
||||
'productCode' => $inv->product->code ?? 'N/A',
|
||||
'unit' => $inv->product->base_unit ?? '個',
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
|
||||
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
|
||||
'batchNumber' => 'BATCH-' . $inv->id, // DB 無批號,暫時模擬,某些 UI 可能還會用到
|
||||
'expiryDate' => '2099-12-31', // DB 無效期,暫時模擬
|
||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||
];
|
||||
});
|
||||
|
||||
// 3. 準備 safetyStockSettings
|
||||
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
|
||||
return !is_null($inv->safety_stock);
|
||||
})->map(function ($inv) {
|
||||
return [
|
||||
'id' => 'ss-' . $inv->id,
|
||||
'warehouseId' => (string) $inv->warehouse_id,
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product->name,
|
||||
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
|
||||
'safetyStock' => (float) $inv->safety_stock,
|
||||
'createdAt' => $inv->created_at->toIso8601String(),
|
||||
'updatedAt' => $inv->updated_at->toIso8601String(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/Inventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventories' => $inventories,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
'availableProducts' => $availableProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(\App\Models\Warehouse $warehouse)
|
||||
{
|
||||
// 取得所有商品供前端選單使用
|
||||
$products = \App\Models\Product::select('id', 'name', 'base_unit')->get()->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'unit' => $product->base_unit,
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/AddInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'inboundDate' => 'required|date',
|
||||
'reason' => 'required|string',
|
||||
'notes' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['items'] as $item) {
|
||||
// 取得或建立庫存紀錄
|
||||
$inventory = $warehouse->inventories()->firstOrCreate(
|
||||
['product_id' => $item['productId']],
|
||||
['quantity' => 0, 'safety_stock' => null]
|
||||
);
|
||||
|
||||
$currentQty = $inventory->quantity;
|
||||
$newQty = $currentQty + $item['quantity'];
|
||||
|
||||
// 更新庫存
|
||||
$inventory->update(['quantity' => $newQty]);
|
||||
|
||||
// 寫入異動紀錄
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動入庫',
|
||||
'quantity' => $item['quantity'],
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
|
||||
'actual_time' => $validated['inboundDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||
->with('success', '庫存記錄已儲存成功');
|
||||
});
|
||||
}
|
||||
|
||||
public function edit(\App\Models\Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
|
||||
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
|
||||
if (str_starts_with($inventoryId, 'mock-inv-')) {
|
||||
return redirect()->back()->with('error', '無法編輯範例資料');
|
||||
}
|
||||
|
||||
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||
|
||||
// 轉換為前端需要的格式
|
||||
$inventoryData = [
|
||||
'id' => (string) $inventory->id,
|
||||
'warehouseId' => (string) $inventory->warehouse_id,
|
||||
'productId' => (string) $inventory->product_id,
|
||||
'productName' => $inventory->product->name,
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'batchNumber' => 'BATCH-' . $inventory->id, // Mock
|
||||
'expiryDate' => '2099-12-31', // Mock
|
||||
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
|
||||
'lastOutboundDate' => null,
|
||||
];
|
||||
|
||||
// 整理異動紀錄
|
||||
$transactions = $inventory->transactions->map(function ($tx) {
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $tx->user ? $tx->user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/EditInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => $inventoryData,
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// 若是 product ID (舊邏輯),先轉為 inventory
|
||||
// 但新路由我們傳的是 inventory ID
|
||||
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
|
||||
|
||||
$inventory = \App\Models\Inventory::find($inventoryId);
|
||||
|
||||
// 如果找不到 (可能是舊路由傳 product ID)
|
||||
if (!$inventory) {
|
||||
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
|
||||
}
|
||||
|
||||
if (!$inventory) {
|
||||
return redirect()->back()->with('error', '找不到庫存紀錄');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'quantity' => 'required|numeric|min:0',
|
||||
// 以下欄位改為 nullable,支援新表單
|
||||
'type' => 'nullable|string',
|
||||
'operation' => 'nullable|in:add,subtract,set',
|
||||
'reason' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
|
||||
'batchNumber' => 'nullable|string',
|
||||
'expiryDate' => 'nullable|date',
|
||||
'lastInboundDate' => 'nullable|date',
|
||||
'lastOutboundDate' => 'nullable|date',
|
||||
]);
|
||||
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
|
||||
$currentQty = $inventory->quantity;
|
||||
$newQty = $validated['quantity'];
|
||||
|
||||
// 判斷操作模式
|
||||
if (isset($validated['operation'])) {
|
||||
$changeQty = 0;
|
||||
switch ($validated['operation']) {
|
||||
case 'add':
|
||||
$changeQty = $validated['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'subtract':
|
||||
$changeQty = -$validated['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'set':
|
||||
$changeQty = $newQty - $currentQty;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 來自編輯頁面,直接 Set
|
||||
$changeQty = $newQty - $currentQty;
|
||||
}
|
||||
|
||||
// 更新庫存
|
||||
$inventory->update(['quantity' => $newQty]);
|
||||
|
||||
// 異動類型映射
|
||||
$type = $validated['type'] ?? 'adjustment';
|
||||
$typeMapping = [
|
||||
'adjustment' => '盤點調整',
|
||||
'purchase_in' => '採購進貨',
|
||||
'sales_out' => '銷售出庫',
|
||||
'return_in' => '退貨入庫',
|
||||
'return_out' => '退貨出庫',
|
||||
'transfer_in' => '撥補入庫',
|
||||
'transfer_out' => '撥補出庫',
|
||||
];
|
||||
$chineseType = $typeMapping[$type] ?? $type;
|
||||
|
||||
// 如果是編輯頁面來的,可能沒有 type,預設為 "盤點調整" 或 "手動編輯"
|
||||
if (!isset($validated['type'])) {
|
||||
$chineseType = '手動編輯';
|
||||
}
|
||||
|
||||
// 寫入異動紀錄
|
||||
// 如果數量沒變,是否要寫紀錄?通常編輯頁面按儲存可能只改了其他欄位(如果有)
|
||||
// 但因為我們目前只存 quantity,如果 quantity 沒變,可以不寫異動,或者寫一筆 0 的異動代表更新屬性
|
||||
if (abs($changeQty) > 0.0001) {
|
||||
$inventory->transactions()->create([
|
||||
'type' => $chineseType,
|
||||
'quantity' => $changeQty,
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => ($validated['reason'] ?? '編輯頁面更新') . ($validated['notes'] ?? ''),
|
||||
'actual_time' => now(), // 手動調整設定為當下
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
|
||||
->with('success', '庫存資料已更新');
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(\App\Models\Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
$inventory = \App\Models\Inventory::findOrFail($inventoryId);
|
||||
|
||||
// 歸零異動
|
||||
if ($inventory->quantity > 0) {
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動編輯',
|
||||
'quantity' => -$inventory->quantity,
|
||||
'balance_before' => $inventory->quantity,
|
||||
'balance_after' => 0,
|
||||
'reason' => '刪除庫存品項',
|
||||
'actual_time' => now(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory->delete();
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||
->with('success', '庫存品項已刪除');
|
||||
}
|
||||
|
||||
public function history(\App\Models\Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||
|
||||
$transactions = $inventory->transactions->map(function ($tx) {
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $tx->user ? $tx->user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => (string) $inventory->id,
|
||||
'productName' => $inventory->product->name,
|
||||
'productCode' => $inventory->product->code,
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Landlord;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'totalTenants' => Tenant::count(),
|
||||
'activeTenants' => Tenant::whereJsonContains('data->is_active', true)->count(),
|
||||
'recentTenants' => Tenant::latest()->take(5)->get()->map(function ($tenant) {
|
||||
return [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||
'domains' => $tenant->domains->pluck('domain')->toArray(),
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Landlord/Dashboard', $stats);
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Landlord/ProfileController.php
Normal file
55
app/Http/Controllers/Landlord/ProfileController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Landlord;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示使用者設定頁面
|
||||
*/
|
||||
public function edit(Request $request)
|
||||
{
|
||||
return Inertia::render('Landlord/Profile/Edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新使用者基本資料
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
|
||||
]);
|
||||
|
||||
$request->user()->update($validated);
|
||||
|
||||
return back()->with('success', '個人資料已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新密碼
|
||||
*/
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('success', '密碼已更新');
|
||||
}
|
||||
}
|
||||
318
app/Http/Controllers/Landlord/TenantController.php
Normal file
318
app/Http/Controllers/Landlord/TenantController.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Landlord;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示租戶列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$tenants = Tenant::with('domains')->get()->map(function ($tenant) {
|
||||
return [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||
'domains' => $tenant->domains->pluck('domain')->toArray(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Landlord/Tenant/Index', [
|
||||
'tenants' => $tenants,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示新增租戶表單
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return Inertia::render('Landlord/Tenant/Create');
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存新租戶
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'short_name' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:100'],
|
||||
'domain' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'id' => $validated['id'],
|
||||
'name' => $validated['name'],
|
||||
'short_name' => $validated['short_name'] ?? null,
|
||||
'email' => $validated['email'] ?? null,
|
||||
'is_active' => true,
|
||||
'branding' => [
|
||||
'logo_path' => 'defaults/logo.png', // 預設 Logo 路徑
|
||||
'login_background_path' => 'defaults/login_bg.jpg', // 預設登入背景
|
||||
'primary_color' => '#4F46E5', // 預設主色系 (Indigo-600)
|
||||
],
|
||||
]);
|
||||
|
||||
// 綁定網域(如果沒有輸入,使用預設網域)
|
||||
$defaultDomain = env('TENANT_DEFAULT_DOMAIN', 'star-erp.test');
|
||||
$domain = !empty($validated['domain'])
|
||||
? $validated['domain']
|
||||
: $validated['id'] . '.' . $defaultDomain;
|
||||
$tenant->domains()->create(['domain' => $domain]);
|
||||
|
||||
return redirect()->route('landlord.tenants.index')
|
||||
->with('success', "租戶 {$validated['name']} 建立成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示單一租戶詳情
|
||||
*/
|
||||
public function show(string $id)
|
||||
{
|
||||
$tenant = Tenant::with('domains')->findOrFail($id);
|
||||
|
||||
$tokens = [];
|
||||
try {
|
||||
tenancy()->initialize($tenant);
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
if ($user) {
|
||||
$tokens = $user->tokens()->orderBy('created_at', 'desc')->get(['id', 'name', 'last_used_at', 'created_at'])->map(function($token) {
|
||||
return [
|
||||
'id' => $token->id,
|
||||
'name' => $token->name,
|
||||
'last_used_at' => $token->last_used_at ? $token->last_used_at->format('Y-m-d H:i') : '未使用',
|
||||
'created_at' => $token->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning("Failed to fetch tokens for tenant {$id}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return Inertia::render('Landlord/Tenant/Show', [
|
||||
'tenant' => [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||
'updated_at' => $tenant->updated_at->format('Y-m-d H:i'),
|
||||
'domains' => $tenant->domains->map(fn($d) => [
|
||||
'id' => $d->id,
|
||||
'domain' => $d->domain,
|
||||
])->toArray(),
|
||||
],
|
||||
'tokens' => $tokens,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示租戶樣式管理頁面
|
||||
*/
|
||||
public function showBranding(Tenant $tenant)
|
||||
{
|
||||
$logoUrl = null;
|
||||
if (isset($tenant->branding['logo_path'])) {
|
||||
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
||||
}
|
||||
|
||||
return Inertia::render('Landlord/Tenant/Branding', [
|
||||
'tenant' => [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'branding' => $tenant->branding ?? [],
|
||||
],
|
||||
'logo_url' => $logoUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯租戶表單
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
$tenant = Tenant::findOrFail($id);
|
||||
|
||||
return Inertia::render('Landlord/Tenant/Edit', [
|
||||
'tenant' => [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name ?? $tenant->id,
|
||||
'short_name' => $tenant->short_name ?? null,
|
||||
'email' => $tenant->email ?? null,
|
||||
'is_active' => $tenant->is_active ?? true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租戶
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$tenant = Tenant::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'short_name' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:100'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$tenant->update($validated);
|
||||
|
||||
return redirect()->route('landlord.tenants.index')
|
||||
->with('success', "租戶 {$validated['name']} 更新成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除租戶
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
$tenant = Tenant::findOrFail($id);
|
||||
$name = $tenant->name ?? $id;
|
||||
|
||||
$tenant->delete();
|
||||
|
||||
return redirect()->route('landlord.tenants.index')
|
||||
->with('success', "租戶 {$name} 已刪除!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增域名到租戶
|
||||
*/
|
||||
public function addDomain(Request $request, string $id)
|
||||
{
|
||||
$tenant = Tenant::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:100', Rule::unique('domains', 'domain')],
|
||||
]);
|
||||
|
||||
$tenant->domains()->create(['domain' => $validated['domain']]);
|
||||
|
||||
return back()->with('success', "域名 {$validated['domain']} 已綁定!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除租戶的域名
|
||||
*/
|
||||
public function removeDomain(string $id, int $domainId)
|
||||
{
|
||||
$tenant = Tenant::findOrFail($id);
|
||||
$domain = $tenant->domains()->findOrFail($domainId);
|
||||
$domainName = $domain->domain;
|
||||
|
||||
$domain->delete();
|
||||
|
||||
return back()->with('success', "域名 {$domainName} 已移除!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租戶品牌樣式設定
|
||||
*/
|
||||
public function updateBranding(Request $request, Tenant $tenant)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'primary_color' => 'required|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||
'text_color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||
]);
|
||||
|
||||
$branding = $tenant->branding ?? [];
|
||||
|
||||
// 處理 Logo 上傳
|
||||
if ($request->hasFile('logo')) {
|
||||
// 刪除舊 Logo
|
||||
if (isset($branding['logo_path'])) {
|
||||
\Storage::disk('public')->delete($branding['logo_path']);
|
||||
}
|
||||
|
||||
// 儲存新 Logo
|
||||
$path = $request->file('logo')->store('tenant-logos', 'public');
|
||||
$branding['logo_path'] = $path;
|
||||
}
|
||||
|
||||
// 更新主色系
|
||||
$branding['primary_color'] = $validated['primary_color'];
|
||||
|
||||
// 如果有傳入字體顏色則更新,否則保留原值(或預設值)
|
||||
if (isset($validated['text_color'])) {
|
||||
$branding['text_color'] = $validated['text_color'];
|
||||
} elseif (!isset($branding['text_color'])) {
|
||||
$branding['text_color'] = '#1a1a1a';
|
||||
}
|
||||
|
||||
$tenant->update(['branding' => $branding]);
|
||||
|
||||
return redirect()->back()->with('success', '樣式設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 API Token (用於 POS)
|
||||
*/
|
||||
public function createToken(Request $request, Tenant $tenant)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
try {
|
||||
// 切換至租戶環境
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// 尋找超級管理員 (假設 ID 1, 或者根據 Role)
|
||||
// 這裡簡單取第一個使用者,通常是 Admin
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
|
||||
if (!$user) {
|
||||
return back()->with('error', '該租戶尚無使用者,無法建立 Token。');
|
||||
}
|
||||
|
||||
// 建立 Token
|
||||
$token = $user->createToken($request->name);
|
||||
|
||||
return back()->with('success', 'Token 建立成功')->with('new_token', $token->plainTextToken);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Token creation failed: " . $e->getMessage());
|
||||
return back()->with('error', 'Token 建立失敗');
|
||||
} finally {
|
||||
// tenancy()->end(); // Laravel Tenancy 自動處理 scope 結束? 通常 Controller request life-cycle?
|
||||
// Landlord controller is Central. Tenancy initialization persists for request.
|
||||
// We should explicit end if we want to be safe, but redirect ends request anyway.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤銷 API Token
|
||||
*/
|
||||
public function revokeToken(Request $request, Tenant $tenant, string $tokenId)
|
||||
{
|
||||
try {
|
||||
tenancy()->initialize($tenant);
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
|
||||
if ($user) {
|
||||
$user->tokens()->where('id', $tokenId)->delete();
|
||||
}
|
||||
|
||||
return back()->with('success', 'Token 已撤銷');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Token 撤銷失敗');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = Product::with('category');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('brand', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category_id') && $request->category_id !== 'all') {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
}
|
||||
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
|
||||
// Define allowed sort fields to prevent SQL injection
|
||||
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit', 'conversion_rate'];
|
||||
if (!in_array($sortField, $allowedSorts)) {
|
||||
$sortField = 'id';
|
||||
}
|
||||
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
||||
$sortDirection = 'desc';
|
||||
}
|
||||
|
||||
// Handle relation sorting (category name) separately if needed, or simple join
|
||||
if ($sortField === 'category_id') {
|
||||
// Join categories for sorting by name? Or just by ID?
|
||||
// Simple approach: sort by ID for now, or join if user wants name sort.
|
||||
// Let's assume standard field sorting first.
|
||||
$query->orderBy('category_id', $sortDirection);
|
||||
} else {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
}
|
||||
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
$categories = \App\Models\Category::where('is_active', true)->get();
|
||||
|
||||
return Inertia::render('Product/Index', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
'base_unit' => 'required|string|max:50',
|
||||
'large_unit' => 'nullable|string|max:50',
|
||||
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
|
||||
'purchase_unit' => 'nullable|string|max:50',
|
||||
], [
|
||||
'name.required' => '商品名稱為必填',
|
||||
'category_id.required' => '請選擇分類',
|
||||
'category_id.exists' => '所選分類不存在',
|
||||
'base_unit.required' => '基本庫存單位為必填',
|
||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||
'conversion_rate.numeric' => '換算率必須為數字',
|
||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||
]);
|
||||
|
||||
// Auto-generate code
|
||||
$prefix = 'P';
|
||||
$lastProduct = Product::withTrashed()->latest('id')->first();
|
||||
$nextId = $lastProduct ? $lastProduct->id + 1 : 1;
|
||||
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$validated['code'] = $code;
|
||||
|
||||
$product = Product::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '商品已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
'base_unit' => 'required|string|max:50',
|
||||
'large_unit' => 'nullable|string|max:50',
|
||||
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
|
||||
]);
|
||||
|
||||
$product->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '商品已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Product $product)
|
||||
{
|
||||
$product->delete();
|
||||
|
||||
return redirect()->back()->with('success', '商品已刪除');
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Vendor;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
|
||||
|
||||
// Search
|
||||
if ($request->search) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('code', 'like', "%{$request->search}%")
|
||||
->orWhereHas('vendor', function($vq) use ($request) {
|
||||
$vq->where('name', 'like', "%{$request->search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filters
|
||||
if ($request->status && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
$sortField = $request->sort_field ?? 'id';
|
||||
$sortDirection = $request->sort_direction ?? 'desc';
|
||||
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
|
||||
|
||||
if (in_array($sortField, $allowedSortFields)) {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
}
|
||||
|
||||
$orders = $query->paginate(15)->withQueryString();
|
||||
|
||||
return Inertia::render('PurchaseOrder/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'sort_field', 'sort_direction']),
|
||||
'warehouses' => Warehouse::all(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $vendor->products->map(function ($product) {
|
||||
return [
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'unit' => $product->base_unit,
|
||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||
];
|
||||
})
|
||||
];
|
||||
});
|
||||
|
||||
$warehouses = Warehouse::all()->map(function ($w) {
|
||||
return [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('PurchaseOrder/Create', [
|
||||
'suppliers' => $vendors,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'required|exists:vendors,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unitPrice' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// 生成單號:YYYYMMDD001
|
||||
$today = now()->format('Ymd');
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
|
||||
->lockForUpdate() // 鎖定以避免並發衝突
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastOrder) {
|
||||
// 取得最後 3 碼序號並加 1
|
||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$sequence = '001';
|
||||
}
|
||||
$code = $today . $sequence;
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
$totalAmount += $item['quantity'] * $item['unitPrice'];
|
||||
}
|
||||
|
||||
// Simple tax calculation (e.g., 5%)
|
||||
$taxAmount = round($totalAmount * 0.05, 2);
|
||||
$grandTotal = $totalAmount + $taxAmount;
|
||||
|
||||
// 確保有一個有效的使用者 ID
|
||||
$userId = auth()->id();
|
||||
if (!$userId) {
|
||||
$user = \App\Models\User::first();
|
||||
if (!$user) {
|
||||
$user = \App\Models\User::create([
|
||||
'name' => '系統管理員',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
$userId = $user->id;
|
||||
}
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
'code' => $code,
|
||||
'vendor_id' => $validated['vendor_id'],
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'user_id' => $userId,
|
||||
'status' => 'draft',
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
'grand_total' => $grandTotal,
|
||||
'remark' => $validated['remark'],
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'product_id' => $item['productId'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unitPrice'],
|
||||
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
||||
]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product'])->findOrFail($id);
|
||||
|
||||
return Inertia::render('PurchaseOrder/Show', [
|
||||
'order' => $order
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
|
||||
|
||||
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $vendor->products->map(function ($product) {
|
||||
return [
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'unit' => $product->base_unit,
|
||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||
];
|
||||
})
|
||||
];
|
||||
});
|
||||
|
||||
$warehouses = Warehouse::all()->map(function ($w) {
|
||||
return [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('PurchaseOrder/Create', [
|
||||
'order' => $order,
|
||||
'suppliers' => $vendors,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$order = PurchaseOrder::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'required|exists:vendors,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unitPrice' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
$totalAmount += $item['quantity'] * $item['unitPrice'];
|
||||
}
|
||||
|
||||
// Simple tax calculation (e.g., 5%)
|
||||
$taxAmount = round($totalAmount * 0.05, 2);
|
||||
$grandTotal = $totalAmount + $taxAmount;
|
||||
|
||||
$order->update([
|
||||
'vendor_id' => $validated['vendor_id'],
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
'grand_total' => $grandTotal,
|
||||
'remark' => $validated['remark'],
|
||||
'status' => $validated['status'],
|
||||
]);
|
||||
|
||||
// Sync items
|
||||
$order->items()->delete();
|
||||
foreach ($validated['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'product_id' => $item['productId'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unitPrice'],
|
||||
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
||||
]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$order = PurchaseOrder::findOrFail($id);
|
||||
|
||||
// Delete associated items first (due to FK constraints if not cascade)
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SafetyStockController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示安全庫存設定頁面
|
||||
*/
|
||||
public function index(Warehouse $warehouse)
|
||||
{
|
||||
$warehouse->load(['inventories.product.category']);
|
||||
|
||||
$allProducts = Product::with('category')->get();
|
||||
|
||||
// 準備可選商品列表
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->category ? $product->category->name : '其他',
|
||||
'unit' => $product->base_unit,
|
||||
];
|
||||
});
|
||||
|
||||
// 準備現有庫存列表 (用於狀態計算)
|
||||
$inventories = $warehouse->inventories->map(function ($inv) {
|
||||
return [
|
||||
'id' => (string) $inv->id,
|
||||
'productId' => (string) $inv->product_id,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'safetyStock' => (float) $inv->safety_stock,
|
||||
];
|
||||
});
|
||||
|
||||
// 準備安全庫存設定列表
|
||||
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
|
||||
return !is_null($inv->safety_stock);
|
||||
})->map(function ($inv) {
|
||||
return [
|
||||
'id' => (string) $inv->id,
|
||||
'warehouseId' => (string) $inv->warehouse_id,
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product->name,
|
||||
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
|
||||
'safetyStock' => (float) $inv->safety_stock,
|
||||
'unit' => $inv->product->base_unit,
|
||||
'updatedAt' => $inv->updated_at->toIso8601String(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
|
||||
return Inertia::render('Warehouse/SafetyStockSettings', [
|
||||
'warehouse' => $warehouse,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
'inventories' => $inventories,
|
||||
'availableProducts' => $availableProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量儲存安全庫存設定
|
||||
*/
|
||||
public function store(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'settings' => 'required|array|min:1',
|
||||
'settings.*.productId' => 'required|exists:products,id',
|
||||
'settings.*.quantity' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['settings'] as $item) {
|
||||
Inventory::updateOrCreate(
|
||||
[
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'product_id' => $item['productId'],
|
||||
],
|
||||
[
|
||||
'safety_stock' => $item['quantity'],
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新單筆安全庫存設定
|
||||
*/
|
||||
public function update(Request $request, Warehouse $warehouse, Inventory $inventory)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'safetyStock' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$inventory->update([
|
||||
'safety_stock' => $validated['safetyStock'],
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除 (歸零) 安全庫存設定
|
||||
*/
|
||||
public function destroy(Warehouse $warehouse, Inventory $inventory)
|
||||
{
|
||||
$inventory->update([
|
||||
'safety_stock' => null,
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存設定已移除');
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 儲存撥補單(建立調撥單並執行庫存轉移)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$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'])
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => ['來源倉庫庫存不足'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 獲取或建立目標倉庫庫存
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $validated['targetWarehouseId'],
|
||||
'product_id' => $validated['productId'],
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'safety_stock' => null, // 預設為 null (未設定),而非 0
|
||||
]
|
||||
);
|
||||
|
||||
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
|
||||
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
|
||||
|
||||
// 3. 執行庫存轉移 (扣除來源)
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $validated['quantity'];
|
||||
$sourceInventory->update(['quantity' => $newSourceQty]);
|
||||
|
||||
// 記錄來源異動
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '撥補出庫',
|
||||
'quantity' => -$validated['quantity'],
|
||||
'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->update(['quantity' => $newTargetQty]);
|
||||
|
||||
// 記錄目標異動
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '撥補入庫',
|
||||
'quantity' => $validated['quantity'],
|
||||
'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', '撥補單已建立且庫存已轉移');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取特定倉庫的庫存列表 (API)
|
||||
*/
|
||||
public function getWarehouseInventories(Warehouse $warehouse)
|
||||
{
|
||||
$inventories = $warehouse->inventories()
|
||||
->with(['product:id,name,base_unit,category_id', 'product.category'])
|
||||
->where('quantity', '>', 0) // 只回傳有庫存的
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product->name,
|
||||
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
||||
'availableQty' => (float) $inv->quantity,
|
||||
'unit' => $inv->product->base_unit,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($inventories);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Vendor;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VendorController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(\Illuminate\Http\Request $request): \Inertia\Response
|
||||
{
|
||||
$query = Vendor::query();
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('tax_id', 'like', "%{$search}%")
|
||||
->orWhere('owner', 'like', "%{$search}%")
|
||||
->orWhere('contact_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
|
||||
$allowedSorts = ['id', 'code', 'name', 'owner', 'contact_name', 'phone'];
|
||||
if (!in_array($sortField, $allowedSorts)) {
|
||||
$sortField = 'id';
|
||||
}
|
||||
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
||||
$sortDirection = 'desc';
|
||||
}
|
||||
|
||||
$vendors = $query->orderBy($sortField, $sortDirection)
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return \Inertia\Inertia::render('Vendor/Index', [
|
||||
'vendors' => $vendors,
|
||||
'filters' => $request->only(['search', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Vendor $vendor): \Inertia\Response
|
||||
{
|
||||
$vendor->load('products');
|
||||
return \Inertia\Inertia::render('Vendor/Show', [
|
||||
'vendor' => $vendor,
|
||||
'products' => \App\Models\Product::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'short_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:8',
|
||||
'owner' => 'nullable|string|max:255',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'tel' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'address' => 'nullable|string',
|
||||
'remark' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Auto-generate code
|
||||
$prefix = 'V';
|
||||
$lastVendor = Vendor::latest('id')->first();
|
||||
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
|
||||
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$validated['code'] = $code;
|
||||
|
||||
Vendor::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '廠商已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(\Illuminate\Http\Request $request, Vendor $vendor)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'short_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:8',
|
||||
'owner' => 'nullable|string|max:255',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'tel' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'address' => 'nullable|string',
|
||||
'remark' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$vendor->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '廠商資料已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Vendor $vendor)
|
||||
{
|
||||
$vendor->delete();
|
||||
|
||||
return redirect()->back()->with('success', '廠商已刪除');
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Vendor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VendorProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* 新增供貨商品 (Attach)
|
||||
*/
|
||||
public function store(Request $request, Vendor $vendor)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'last_price' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// 檢查是否已存在
|
||||
if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) {
|
||||
return redirect()->back()->with('error', '該商品已在供貨清單中');
|
||||
}
|
||||
|
||||
$vendor->products()->attach($validated['product_id'], [
|
||||
'last_price' => $validated['last_price'] ?? null
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '供貨商品已新增');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新供貨商品資訊 (Update Pivot)
|
||||
*/
|
||||
public function update(Request $request, Vendor $vendor, $productId)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'last_price' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
$vendor->products()->updateExistingPivot($productId, [
|
||||
'last_price' => $validated['last_price'] ?? null
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '供貨資訊已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除供貨商品 (Detach)
|
||||
*/
|
||||
public function destroy(Vendor $vendor, $productId)
|
||||
{
|
||||
$vendor->products()->detach($productId);
|
||||
|
||||
return redirect()->back()->with('success', '供貨商品已移除');
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
use App\Models\Warehouse;
|
||||
|
||||
use Inertia\Inertia;
|
||||
|
||||
class WarehouseController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Warehouse::query();
|
||||
|
||||
if ($request->has('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
|
||||
->withCount(['inventories as low_stock_count' => function ($query) {
|
||||
$query->whereColumn('quantity', '<', 'safety_stock');
|
||||
}])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('Warehouse/Index', [
|
||||
'warehouses' => $warehouses,
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Auto-generate code
|
||||
$prefix = 'WH';
|
||||
$lastWarehouse = Warehouse::latest('id')->first();
|
||||
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
||||
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
$validated['code'] = $code;
|
||||
|
||||
Warehouse::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '倉庫已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$warehouse->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '倉庫資訊已更新');
|
||||
}
|
||||
|
||||
public function destroy(Warehouse $warehouse)
|
||||
{
|
||||
// 真實刪除
|
||||
$warehouse->delete();
|
||||
|
||||
return redirect()->back()->with('success', '倉庫已刪除');
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,68 @@ class HandleInertiaRequests extends Middleware
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
$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,
|
||||
'app_version' => config('app.version'),
|
||||
'auth' => [
|
||||
'user' => $user ? [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'username' => $user->username ?? null,
|
||||
// 權限資料
|
||||
'roles' => $user->getRoleNames(),
|
||||
'role_labels' => $user->roles->pluck('display_name'),
|
||||
'permissions' => $user->getAllPermissions()->pluck('name')->toArray(),
|
||||
] : null,
|
||||
],
|
||||
'flash' => [
|
||||
'success' => $request->session()->get('success'),
|
||||
'error' => $request->session()->get('error'),
|
||||
'new_token' => $request->session()->get('new_token'),
|
||||
],
|
||||
'branding' => function () {
|
||||
$tenant = tenancy()->tenant;
|
||||
|
||||
// 決定名稱顯示邏輯
|
||||
$fullName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
|
||||
$shortName = $tenant ? ($tenant->short_name ?? $fullName) : 'Start ERP';
|
||||
|
||||
$logoUrl = null;
|
||||
if ($tenant && isset($tenant->branding['logo_path'])) {
|
||||
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
||||
} elseif (!$tenant) {
|
||||
$logoUrl = \Storage::url('defaults/logo.png');
|
||||
}
|
||||
|
||||
$brandingData = [
|
||||
'name' => $fullName,
|
||||
'short_name' => $shortName,
|
||||
'logo_url' => $logoUrl,
|
||||
'primary_color' => $tenant->branding['primary_color'] ?? ($tenant ? '#01ab83' : '#4F46E5'),
|
||||
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
|
||||
];
|
||||
|
||||
// 同步分享給 Blade View (給 app.blade.php 使用 Favicon)
|
||||
\Illuminate\Support\Facades\View::share('branding', $brandingData);
|
||||
|
||||
return $brandingData;
|
||||
},
|
||||
'notifications' => function () use ($request) {
|
||||
return $request->user() ? [
|
||||
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
] : null;
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
20
app/Http/Middleware/PreventAccessFromTenantDomains.php
Normal file
20
app/Http/Middleware/PreventAccessFromTenantDomains.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
class PreventAccessFromTenantDomains
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// 如果租戶已初始化 (代表是從租戶域名存取),則禁止訪問 Landlord 路由
|
||||
if (tenancy()->initialized) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
38
app/Http/Middleware/UniversalTenancy.php
Normal file
38
app/Http/Middleware/UniversalTenancy.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UniversalTenancy
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 判斷是否為中央域名
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
|
||||
// [Hack] Demo 環境特殊規則:
|
||||
// 如果設定了 demo_tenant_port (e.g. 8081),且請求端口相符,強制視為租戶請求
|
||||
$demoPort = config('tenancy.demo_tenant_port');
|
||||
if ($demoPort && $request->getPort() == $demoPort) {
|
||||
return app(InitializeTenancyByDomain::class)->handle($request, $next);
|
||||
}
|
||||
|
||||
if (in_array($request->getHost(), $centralDomains)) {
|
||||
// 如果是中央域名,不進行租戶初始化,直接繼續往下執行 (使用預設資料庫)
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 如果不是中央域名,嘗試透過域名初始化租戶
|
||||
// 若找不到租戶,InitializeTenancyByDomain 會拋出異常
|
||||
return app(InitializeTenancyByDomain::class)->handle($request, $next);
|
||||
}
|
||||
}
|
||||
59
app/Mail/PaymentReminderMail.php
Normal file
59
app/Mail/PaymentReminderMail.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PaymentReminderMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $fees;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct($fees)
|
||||
{
|
||||
$this->fees = $fees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$tenantName = tenant('name') ?? '系統';
|
||||
return new Envelope(
|
||||
subject: "【{$tenantName}】公共事業費繳費/逾期通知",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.payment-reminder',
|
||||
with: [
|
||||
'fees' => $this->fees,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
54
app/Mail/TestNotificationMail.php
Normal file
54
app/Mail/TestNotificationMail.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TestNotificationMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$tenantName = tenant('name') ?? '系統';
|
||||
return new Envelope(
|
||||
subject: "【{$tenantName}】電子郵件通知測試",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.test-notification',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the products for the category.
|
||||
*/
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Inventory extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventoryFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'warehouse_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'safety_stock',
|
||||
'location',
|
||||
];
|
||||
|
||||
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryTransaction::class);
|
||||
}
|
||||
|
||||
public function lastOutgoingTransaction()
|
||||
{
|
||||
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
||||
'actual_time' => 'max',
|
||||
'id' => 'max',
|
||||
], function ($query) {
|
||||
$query->where('quantity', '<', 0);
|
||||
});
|
||||
}
|
||||
|
||||
public function lastIncomingTransaction()
|
||||
{
|
||||
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
||||
'actual_time' => 'max',
|
||||
'id' => 'max',
|
||||
], function ($query) {
|
||||
$query->where('quantity', '>', 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\User;
|
||||
|
||||
class InventoryTransaction extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'type',
|
||||
'quantity',
|
||||
'balance_before',
|
||||
'balance_after',
|
||||
'reason',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
'user_id',
|
||||
'actual_time',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'actual_time' => 'datetime',
|
||||
];
|
||||
|
||||
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'category_id',
|
||||
'brand',
|
||||
'specification',
|
||||
'base_unit',
|
||||
'large_unit',
|
||||
'conversion_rate',
|
||||
'purchase_unit',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'conversion_rate' => 'decimal:4',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the category that owns the product.
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
|
||||
}
|
||||
|
||||
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
|
||||
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Warehouse::class, 'inventories')
|
||||
->withPivot(['quantity', 'safety_stock', 'location'])
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class PurchaseOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'vendor_id',
|
||||
'warehouse_id',
|
||||
'user_id',
|
||||
'status',
|
||||
'expected_delivery_date',
|
||||
'total_amount',
|
||||
'tax_amount',
|
||||
'grand_total',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expected_delivery_date' => 'date',
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'grand_total' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'poNumber',
|
||||
'supplierId',
|
||||
'supplierName',
|
||||
'expectedDate',
|
||||
'totalAmount',
|
||||
'createdBy',
|
||||
'warehouse_name',
|
||||
'createdAt',
|
||||
];
|
||||
|
||||
public function getCreatedAtAttribute()
|
||||
{
|
||||
return $this->attributes['created_at'];
|
||||
}
|
||||
|
||||
public function getPoNumberAttribute(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function getSupplierIdAttribute(): string
|
||||
{
|
||||
return (string) $this->vendor_id;
|
||||
}
|
||||
|
||||
public function getSupplierNameAttribute(): string
|
||||
{
|
||||
return $this->vendor ? $this->vendor->name : '';
|
||||
}
|
||||
|
||||
public function getExpectedDateAttribute(): ?string
|
||||
{
|
||||
return $this->expected_delivery_date ? $this->expected_delivery_date->format('Y-m-d') : null;
|
||||
}
|
||||
|
||||
public function getTotalAmountAttribute(): float
|
||||
{
|
||||
return (float) ($this->attributes['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
public function getCreatedByAttribute(): string
|
||||
{
|
||||
return $this->user ? $this->user->name : '系統';
|
||||
}
|
||||
|
||||
public function getWarehouseNameAttribute(): string
|
||||
{
|
||||
return $this->warehouse ? $this->warehouse->name : '';
|
||||
}
|
||||
|
||||
public function vendor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Vendor::class);
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItem::class);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PurchaseOrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'purchase_order_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
'received_quantity',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'received_quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'productName',
|
||||
'unit',
|
||||
'productId',
|
||||
'unitPrice',
|
||||
];
|
||||
|
||||
public function getProductIdAttribute(): string
|
||||
{
|
||||
return (string) $this->attributes['product_id'];
|
||||
}
|
||||
|
||||
public function getUnitPriceAttribute(): float
|
||||
{
|
||||
return (float) $this->attributes['unit_price'];
|
||||
}
|
||||
|
||||
public function getProductNameAttribute(): string
|
||||
{
|
||||
return $this->product ? $this->product->name : '';
|
||||
}
|
||||
|
||||
public function getUnitAttribute(): string
|
||||
{
|
||||
return $this->product ? $this->product->base_unit : '';
|
||||
}
|
||||
|
||||
public function purchaseOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Vendor extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'short_name',
|
||||
'tax_id',
|
||||
'owner',
|
||||
'contact_name',
|
||||
'tel',
|
||||
'phone',
|
||||
'email',
|
||||
'address',
|
||||
'remark'
|
||||
];
|
||||
public function products(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'product_vendor')
|
||||
->withPivot('last_price')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function purchaseOrders(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrder::class);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Warehouse extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'address',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
|
||||
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'inventories')
|
||||
->withPivot(['quantity', 'safety_stock', 'location'])
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
38
app/Modules/Core/Contracts/CoreServiceInterface.php
Normal file
38
app/Modules/Core/Contracts/CoreServiceInterface.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface CoreServiceInterface
|
||||
{
|
||||
/**
|
||||
* Get multiple users by their IDs.
|
||||
*
|
||||
* @param array $ids
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersByIds(array $ids): Collection;
|
||||
|
||||
/**
|
||||
* Get a specific user by ID.
|
||||
*
|
||||
* @param int $id
|
||||
* @return object|null
|
||||
*/
|
||||
public function getUser(int $id): ?object;
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllUsers(): Collection;
|
||||
|
||||
/**
|
||||
* Get the system user or create one if not exists.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
public function ensureSystemUserExists();
|
||||
}
|
||||
142
app/Modules/Core/Controllers/ActivityLogController.php
Normal file
142
app/Modules/Core/Controllers/ActivityLogController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class ActivityLogController extends Controller
|
||||
{
|
||||
private function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Modules\Core\Models\User' => '使用者',
|
||||
'App\Modules\Core\Models\Role' => '角色',
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Procurement\Models\Vendor' => '廠商',
|
||||
'App\Modules\Inventory\Models\Category' => '商品分類',
|
||||
'App\Modules\Inventory\Models\Unit' => '單位',
|
||||
'App\Modules\Procurement\Models\PurchaseOrder' => '採購單',
|
||||
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
|
||||
'App\Modules\Inventory\Models\Inventory' => '庫存',
|
||||
'App\Modules\Inventory\Models\InventoryTransaction' => '庫存異動紀錄',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
|
||||
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
|
||||
'App\Modules\Production\Models\Recipe' => '生產配方',
|
||||
'App\Modules\Production\Models\RecipeItem' => '配方品項',
|
||||
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
|
||||
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
|
||||
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
|
||||
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
|
||||
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
|
||||
];
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$sortBy = $request->input('sort_by', 'created_at');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
|
||||
$search = $request->input('search');
|
||||
$dateStart = $request->input('date_start');
|
||||
$dateEnd = $request->input('date_end');
|
||||
$event = $request->input('event');
|
||||
$subjectType = $request->input('subject_type');
|
||||
$causerId = $request->input('causer_id');
|
||||
|
||||
$query = Activity::with('causer');
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('log_name', 'like', "%{$search}%")
|
||||
->orWhere('properties', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($dateStart) {
|
||||
$query->whereDate('created_at', '>=', $dateStart);
|
||||
}
|
||||
|
||||
if ($dateEnd) {
|
||||
$query->whereDate('created_at', '<=', $dateEnd);
|
||||
}
|
||||
|
||||
if ($event) {
|
||||
$query->where('event', $event);
|
||||
}
|
||||
|
||||
if ($subjectType) {
|
||||
$query->where('subject_type', $subjectType);
|
||||
}
|
||||
|
||||
if ($causerId) {
|
||||
$query->where('causer_id', $causerId);
|
||||
}
|
||||
|
||||
if ($sortBy === 'created_at') {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
$query->latest();
|
||||
}
|
||||
|
||||
$activities = $query->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($activity) {
|
||||
$subjectMap = $this->getSubjectMap();
|
||||
|
||||
$eventMap = [
|
||||
'created' => '新增',
|
||||
'updated' => '更新',
|
||||
'deleted' => '刪除',
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => $activity->id,
|
||||
'description' => $eventMap[$activity->event] ?? $activity->event,
|
||||
'subject_type' => $subjectMap[$activity->subject_type] ?? class_basename($activity->subject_type),
|
||||
'event' => $activity->event,
|
||||
'causer' => $activity->causer ? $activity->causer->name : 'System',
|
||||
'created_at' => $activity->created_at->format('Y-m-d H:i:s'),
|
||||
'properties' => $activity->properties,
|
||||
];
|
||||
});
|
||||
|
||||
// 準備用於前端篩選的主題類型
|
||||
$subjectTypes = collect($this->getSubjectMap())->map(function ($label, $value) {
|
||||
return ['label' => $label, 'value' => $value];
|
||||
})->values();
|
||||
|
||||
// 取得用於操作者篩選的使用者
|
||||
$users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
|
||||
->map(function ($user) {
|
||||
return ['label' => $user->name, 'value' => (string) $user->id];
|
||||
});
|
||||
|
||||
return Inertia::render('Admin/ActivityLog/Index', [
|
||||
'activities' => $activities,
|
||||
'filters' => [
|
||||
'per_page' => $request->input('per_page', '10'),
|
||||
'sort_by' => $request->input('sort_by'),
|
||||
'sort_order' => $request->input('sort_order'),
|
||||
'search' => $request->input('search'),
|
||||
'date_start' => $request->input('date_start'),
|
||||
'date_end' => $request->input('date_end'),
|
||||
'event' => $request->input('event'),
|
||||
'subject_type' => $request->input('subject_type'),
|
||||
'causer_id' => $request->input('causer_id'),
|
||||
],
|
||||
'subject_types' => $subjectTypes,
|
||||
'users' => $users,
|
||||
]);
|
||||
}
|
||||
}
|
||||
91
app/Modules/Core/Controllers/Auth/LoginController.php
Normal file
91
app/Modules/Core/Controllers/Auth/LoginController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function show()
|
||||
{
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
|
||||
// [Hack] Demo 環境特殊規則
|
||||
$demoPort = config('tenancy.demo_tenant_port');
|
||||
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
|
||||
return Inertia::render('Landlord/Auth/Login');
|
||||
}
|
||||
|
||||
return Inertia::render('Auth/Login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'username' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
], [
|
||||
'username.required' => '請輸入帳號',
|
||||
'password.required' => '請輸入密碼',
|
||||
]);
|
||||
|
||||
$credentials = $request->only('username', 'password');
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
// Check activation status
|
||||
if (!Auth::user()->is_active) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'username' => '此帳號已被停用,請聯繫管理員。',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
// [Hack] Demo 環境特殊規則
|
||||
$demoPort = config('tenancy.demo_tenant_port');
|
||||
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
|
||||
return Inertia::location(route('landlord.dashboard'));
|
||||
}
|
||||
|
||||
return Inertia::location(route('dashboard'));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'username' => '帳號或密碼錯誤。',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// 強制清除 Session Cookie (對付 HTTPS/Proxy 環境下的殘留問題)
|
||||
$sessionCookieName = config('session.cookie');
|
||||
Cookie::queue(Cookie::forget($sessionCookieName));
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
132
app/Modules/Core/Controllers/DashboardController.php
Normal file
132
app/Modules/Core/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Sales\Contracts\SalesServiceInterface;
|
||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
protected $salesService;
|
||||
protected $productionService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService,
|
||||
SalesServiceInterface $salesService,
|
||||
ProductionServiceInterface $productionService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
$this->salesService = $salesService;
|
||||
$this->productionService = $productionService;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
|
||||
$demoPort = config('tenancy.demo_tenant_port');
|
||||
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
|
||||
return redirect()->route('landlord.dashboard');
|
||||
}
|
||||
|
||||
$invStats = $this->inventoryService->getDashboardStats();
|
||||
$procStats = $this->procurementService->getDashboardStats();
|
||||
|
||||
// 銷售統計 (本月營收)
|
||||
$thisMonthRevenue = $this->salesService->getThisMonthRevenue();
|
||||
|
||||
// 生產統計 (待核准工單)
|
||||
$pendingProductionCount = $this->productionService->getPendingProductionCount();
|
||||
|
||||
// 生產狀態分佈
|
||||
// 近30日銷售趨勢 (Area Chart)
|
||||
$salesTrend = $this->salesService->getSalesTrend();
|
||||
|
||||
// 本月熱銷商品 Top 5 (Bar Chart)
|
||||
$topSellingItems = $this->salesService->getTopSellingProducts();
|
||||
$productIds = $topSellingItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$productsMap = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
$topSellingProducts = $topSellingItems->map(function ($item) use ($productsMap) {
|
||||
$product = $productsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : $item->product_code,
|
||||
'amount' => (int)$item->total_amount,
|
||||
];
|
||||
});
|
||||
|
||||
// 庫存積壓排行 (Top Inventory Value)
|
||||
$topInventoryValueItems = $this->inventoryService->getTopInventoryValue();
|
||||
$invProductIds = $topInventoryValueItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$invProductsMap = $this->inventoryService->getProductsByIds($invProductIds)->keyBy('id');
|
||||
|
||||
$topInventoryValue = $topInventoryValueItems->map(function ($item) use ($invProductsMap) {
|
||||
$product = $invProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : 'Unknown Product',
|
||||
'code' => $product ? $product->code : '',
|
||||
'value' => (int)$item->total_value,
|
||||
];
|
||||
});
|
||||
|
||||
// 熱銷數量排行 (Top Selling by Quantity)
|
||||
$topSellingQtyItems = $this->salesService->getTopSellingByQuantity();
|
||||
$qtyProductIds = $topSellingQtyItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$qtyProductsMap = $this->inventoryService->getProductsByIds($qtyProductIds)->keyBy('id');
|
||||
|
||||
$topSellingByQuantity = $topSellingQtyItems->map(function ($item) use ($qtyProductsMap) {
|
||||
$product = $qtyProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : $item->product_code,
|
||||
'code' => $item->product_code,
|
||||
'value' => (int)$item->total_quantity,
|
||||
];
|
||||
});
|
||||
|
||||
// 即將過期商品 (Expiring Soon)
|
||||
$expiringItems = $this->inventoryService->getExpiringSoon();
|
||||
$expiringProductIds = $expiringItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$expiringProductsMap = $this->inventoryService->getProductsByIds($expiringProductIds)->keyBy('id');
|
||||
|
||||
$expiringSoon = $expiringItems->map(function ($item) use ($expiringProductsMap) {
|
||||
$product = $expiringProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : 'Unknown Product',
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $item->expiry_date->format('Y-m-d'),
|
||||
'quantity' => (int)$item->quantity,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'stats' => [
|
||||
'totalItems' => $invStats['productsCount'],
|
||||
'lowStockCount' => $invStats['lowStockCount'],
|
||||
'negativeCount' => $invStats['negativeCount'] ?? 0,
|
||||
'expiringCount' => $invStats['expiringCount'] ?? 0,
|
||||
'totalInventoryValue' => $invStats['totalInventoryValue'] ?? 0,
|
||||
'thisMonthRevenue' => $thisMonthRevenue,
|
||||
'pendingOrdersCount' => $procStats['pendingOrdersCount'] ?? 0,
|
||||
'pendingTransferCount' => $invStats['pendingTransferCount'] ?? 0,
|
||||
'pendingProductionCount' => $pendingProductionCount,
|
||||
'todoCount' => ($procStats['pendingOrdersCount'] ?? 0) + ($invStats['pendingTransferCount'] ?? 0) + $pendingProductionCount,
|
||||
'salesTrend' => $salesTrend,
|
||||
'topSellingProducts' => $topSellingProducts,
|
||||
'topInventoryValue' => $topInventoryValue,
|
||||
'topSellingByQuantity' => $topSellingByQuantity,
|
||||
'expiringSoon' => $expiringSoon,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
41
app/Modules/Core/Controllers/NotificationController.php
Normal file
41
app/Modules/Core/Controllers/NotificationController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark a specific notification as read.
|
||||
*/
|
||||
public function markAsRead(Request $request, string $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read.
|
||||
*/
|
||||
public function markAllAsRead(Request $request)
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for new notifications.
|
||||
*/
|
||||
public function check(Request $request)
|
||||
{
|
||||
return response()->json([
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
app/Modules/Core/Controllers/ProfileController.php
Normal file
56
app/Modules/Core/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示使用者設定頁面
|
||||
*/
|
||||
public function edit(Request $request)
|
||||
{
|
||||
return Inertia::render('Profile/Edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新使用者基本資料
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
|
||||
]);
|
||||
|
||||
$request->user()->update($validated);
|
||||
|
||||
return back()->with('success', '個人資料已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新密碼
|
||||
*/
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('success', '密碼已更新');
|
||||
}
|
||||
}
|
||||
231
app/Modules/Core/Controllers/RoleController.php
Normal file
231
app/Modules/Core/Controllers/RoleController.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
|
||||
$query = Role::withCount('users', 'permissions')
|
||||
->with('users:id,name,username');
|
||||
|
||||
// 處理排序
|
||||
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
$query->orderBy('id', 'asc');
|
||||
}
|
||||
|
||||
$roles = $query->get();
|
||||
|
||||
return Inertia::render('Admin/Role/Index', [
|
||||
'roles' => $roles,
|
||||
'filters' => $request->only(['sort_by', 'sort_order']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示建立新資源的表單。
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$permissions = $this->getGroupedPermissions();
|
||||
|
||||
return Inertia::render('Admin/Role/Create', [
|
||||
'groupedPermissions' => $permissions
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
|
||||
'display_name' => ['required', 'string', 'max:255'],
|
||||
'permissions' => ['array'],
|
||||
'permissions.*' => ['exists:permissions,name']
|
||||
]);
|
||||
|
||||
$role = Role::create([
|
||||
'name' => $validated['name'],
|
||||
'display_name' => $validated['display_name']
|
||||
]);
|
||||
|
||||
if (!empty($validated['permissions'])) {
|
||||
$role->syncPermissions($validated['permissions']);
|
||||
}
|
||||
|
||||
return redirect()->route('roles.index')->with('success', '角色建立成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯指定資源的表單。
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
$role = Role::with('permissions')->findOrFail($id);
|
||||
|
||||
// 禁止編輯超級管理員角色
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->route('roles.index')->with('error', '超級管理員角色不可編輯');
|
||||
}
|
||||
|
||||
$groupedPermissions = $this->getGroupedPermissions();
|
||||
$currentPermissions = $role->permissions->pluck('name')->toArray();
|
||||
|
||||
return Inertia::render('Admin/Role/Edit', [
|
||||
'role' => $role,
|
||||
'groupedPermissions' => $groupedPermissions,
|
||||
'currentPermissions' => $currentPermissions
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->route('roles.index')->with('error', '超級管理員角色不可變更');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($role->id)],
|
||||
'display_name' => ['required', 'string', 'max:255'],
|
||||
'permissions' => ['array'],
|
||||
'permissions.*' => ['exists:permissions,name']
|
||||
]);
|
||||
|
||||
$role->update([
|
||||
'name' => $validated['name'],
|
||||
'display_name' => $validated['display_name']
|
||||
]);
|
||||
|
||||
if (isset($validated['permissions'])) {
|
||||
$role->syncPermissions($validated['permissions']);
|
||||
}
|
||||
|
||||
return back()->with('success', '角色更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
$role = Role::withCount('users')->findOrFail($id);
|
||||
|
||||
if ($role->name === 'super-admin') {
|
||||
return back()->with('error', '超級管理員角色不可刪除');
|
||||
}
|
||||
|
||||
if ($role->users_count > 0) {
|
||||
return back()->with('error', "尚有 {$role->users_count} 位使用者屬於此角色,無法刪除");
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return redirect()->route('roles.index')->with('success', '角色已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得並分組權限
|
||||
*/
|
||||
private function getGroupedPermissions()
|
||||
{
|
||||
$allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get();
|
||||
$grouped = [];
|
||||
|
||||
foreach ($allPermissions as $permission) {
|
||||
$parts = explode('.', $permission->name);
|
||||
$group = $parts[0];
|
||||
$action = $parts[1] ?? '';
|
||||
|
||||
// 特定權限遷移邏輯
|
||||
if ($permission->name === 'inventory.view_cost') {
|
||||
$group = 'inventory';
|
||||
}
|
||||
|
||||
// 移除不再使用的權限選項
|
||||
if (in_array($permission->name, ['inventory.count', 'inventory.transfer'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($grouped[$group])) {
|
||||
$grouped[$group] = [];
|
||||
}
|
||||
|
||||
$grouped[$group][] = $permission;
|
||||
}
|
||||
|
||||
// 依照側邊欄順序定義
|
||||
$groupDefinitions = [
|
||||
'products' => '商品資料管理',
|
||||
'warehouses' => '倉庫管理',
|
||||
'inventory' => '庫存資料管理',
|
||||
'inventory_count' => '庫存盤點管理',
|
||||
'inventory_adjust' => '庫存盤調管理',
|
||||
'inventory_transfer' => '庫存調撥管理',
|
||||
'inventory_report' => '庫存報表',
|
||||
'inventory_traceability' => '批號溯源',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'purchase_returns' => '採購退回管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
'delivery_notes' => '出貨單管理',
|
||||
'recipes' => '配方管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
'account_payables' => '應付帳款',
|
||||
'sales_imports' => '銷售單匯入管理',
|
||||
'sales_orders' => '銷售訂單管理',
|
||||
'store_requisitions' => '門市叫貨申請',
|
||||
'procurement_analysis' => '採購統計分析',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'system' => '系統管理',
|
||||
];
|
||||
|
||||
$result = [];
|
||||
foreach ($groupDefinitions as $key => $displayName) {
|
||||
if (isset($grouped[$key])) {
|
||||
$result[] = [
|
||||
'key' => $key,
|
||||
'name' => $displayName,
|
||||
'permissions' => $grouped[$key]
|
||||
];
|
||||
unset($grouped[$key]); // 從待處理中移除
|
||||
}
|
||||
}
|
||||
|
||||
// 處理剩餘未定義在 groupDefinitions 中的群組 (安全機制)
|
||||
foreach ($grouped as $key => $permissions) {
|
||||
$result[] = [
|
||||
'key' => $key,
|
||||
'name' => ucfirst($key),
|
||||
'permissions' => $permissions
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
97
app/Modules/Core/Controllers/SystemSettingController.php
Normal file
97
app/Modules/Core/Controllers/SystemSettingController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Core\Models\SystemSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SystemSettingController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示系統設定頁面
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$settings = SystemSetting::all()->groupBy('group');
|
||||
|
||||
return Inertia::render('Admin/Setting/Index', [
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新系統設定
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'settings' => 'required|array',
|
||||
'settings.*.key' => 'required|string|exists:system_settings,key',
|
||||
'settings.*.value' => 'nullable',
|
||||
]);
|
||||
|
||||
foreach ($validated['settings'] as $item) {
|
||||
SystemSetting::where('key', $item['key'])->update([
|
||||
'value' => $item['value']
|
||||
]);
|
||||
}
|
||||
|
||||
// 清除記憶體快取,確保後續讀取拿到最新值
|
||||
SystemSetting::clearCache();
|
||||
|
||||
return redirect()->back()->with('success', '系統設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 測試發送通知信
|
||||
*/
|
||||
public function testNotification(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'settings' => 'required|array',
|
||||
'settings.*.key' => 'required|string',
|
||||
'settings.*.value' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$settings = collect($validated['settings'])->pluck('value', 'key');
|
||||
|
||||
$senderEmail = $settings['notification.utility_fee_sender_email'] ?? null;
|
||||
$senderPassword = $settings['notification.utility_fee_sender_password'] ?? null;
|
||||
$recipientEmailsStr = $settings['notification.utility_fee_recipient_emails'] ?? null;
|
||||
|
||||
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
|
||||
return back()->with('error', '請先填寫完整發信帳號、密碼及收件者信箱。');
|
||||
}
|
||||
|
||||
// 動態覆寫應用程式名稱與 SMTP Config
|
||||
$tenantName = tenant('name') ?? config('app.name');
|
||||
config([
|
||||
'app.name' => $tenantName,
|
||||
'mail.mailers.smtp.username' => $senderEmail,
|
||||
'mail.mailers.smtp.password' => $senderPassword,
|
||||
'mail.from.address' => $senderEmail,
|
||||
'mail.from.name' => $tenantName . ' (系統通知)'
|
||||
]);
|
||||
|
||||
// 清理原先可能的 Mailer 實例,確保使用新的 Config
|
||||
\Illuminate\Support\Facades\Mail::purge();
|
||||
|
||||
// 解析收件者
|
||||
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
|
||||
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
|
||||
|
||||
if (empty($validRecipients)) {
|
||||
return back()->with('error', '無效的收件者 Email 格式。');
|
||||
}
|
||||
|
||||
try {
|
||||
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\TestNotificationMail());
|
||||
return back()->with('success', '測試信件已成功發送,請檢查收件匣。');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', '測試發信失敗: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
354
app/Modules/Core/Controllers/UserController.php
Normal file
354
app/Modules/Core/Controllers/UserController.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Core\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
$search = $request->input('search');
|
||||
|
||||
$roleId = $request->input('role');
|
||||
$isActive = $request->input('is_active'); // 'all', '1', '0'
|
||||
|
||||
$query = User::query();
|
||||
|
||||
// 隱藏超級管理員:若非 super-admin,則不可看到 super-admin 過往
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$query->whereDoesntHave('roles', function ($q) {
|
||||
$q->where('name', 'super-admin');
|
||||
});
|
||||
|
||||
// 預載入角色時也過濾掉 super-admin 標籤
|
||||
$query->with(['roles' => function ($q) {
|
||||
$q->select('id', 'name', 'display_name')
|
||||
->where('name', '!=', 'super-admin');
|
||||
}]);
|
||||
} else {
|
||||
$query->with(['roles:id,name,display_name']);
|
||||
}
|
||||
|
||||
// 處理搜尋
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 處理角色篩選
|
||||
if ($roleId && $roleId !== 'all') {
|
||||
$query->whereHas('roles', function ($q) use ($roleId) {
|
||||
$q->where('id', $roleId);
|
||||
});
|
||||
}
|
||||
|
||||
// 處理狀態篩選
|
||||
if ($isActive !== null && $isActive !== 'all') {
|
||||
$query->where('is_active', $isActive === '1' || $isActive === 'true');
|
||||
}
|
||||
|
||||
// 處理排序
|
||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
$query->orderBy('id', 'asc');
|
||||
}
|
||||
|
||||
$users = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// 只能看到自己權限以下的角色
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Index', [
|
||||
'users' => $users,
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role', 'is_active']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示建立新資源的表單。
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$rolesQuery = Role::query();
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->pluck('display_name', 'name');
|
||||
|
||||
return Inertia::render('Admin/User/Create', [
|
||||
'roles' => $roles
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users'],
|
||||
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.required' => '請輸入密碼',
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
'password.confirmed' => '密碼確認不符',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
|
||||
'password' => Hash::make($validated['password']),
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
if (!empty($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
$user->syncRoles($validated['roles']);
|
||||
|
||||
// 更新 'created' 紀錄以包含角色資訊
|
||||
$activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user))
|
||||
->where('subject_id', $user->id)
|
||||
->where('event', 'created')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($activity) {
|
||||
$roleNames = $user->roles()->pluck('display_name')->join(', ');
|
||||
$properties = $activity->properties->toArray();
|
||||
$properties['attributes']['role_id'] = $roleNames;
|
||||
$activity->properties = $properties;
|
||||
$activity->save();
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('users.index')->with('success', '使用者建立成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯指定資源的表單。
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
$user = User::with('roles')->findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能編輯 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Edit', [
|
||||
'user' => $user,
|
||||
'roles' => $roles,
|
||||
'currentRoles' => $user->getRoleNames()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能更新 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
'password.confirmed' => '密碼確認不符',
|
||||
]);
|
||||
|
||||
// 1. 準備資料並偵測變更
|
||||
$userData = [
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
];
|
||||
|
||||
$user->fill($userData);
|
||||
|
||||
// 捕捉變更屬性以進行手動記錄
|
||||
$dirty = $user->getDirty();
|
||||
$oldAttributes = [];
|
||||
$newAttributes = [];
|
||||
|
||||
foreach ($dirty as $key => $value) {
|
||||
$oldAttributes[$key] = $user->getOriginal($key);
|
||||
$newAttributes[$key] = $value;
|
||||
}
|
||||
|
||||
// 儲存但不觸發事件(防止重複記錄)
|
||||
$user->saveQuietly();
|
||||
|
||||
// 2. 處理角色
|
||||
$roleChanges = null;
|
||||
if (isset($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
|
||||
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
$user->syncRoles($validated['roles']);
|
||||
$newRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
|
||||
if ($oldRoles !== $newRoles) {
|
||||
$roleChanges = [
|
||||
'old' => $oldRoles,
|
||||
'new' => $newRoles
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 手動記錄活動(單一整合記錄)
|
||||
if (!empty($newAttributes) || $roleChanges) {
|
||||
$properties = [
|
||||
'attributes' => $newAttributes,
|
||||
'old' => $oldAttributes,
|
||||
];
|
||||
|
||||
if ($roleChanges) {
|
||||
$properties['attributes']['role_id'] = $roleChanges['new'];
|
||||
$properties['old']['role_id'] = $roleChanges['old'];
|
||||
}
|
||||
|
||||
activity()
|
||||
->performedOn($user)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties($properties)
|
||||
->tap(function (\Spatie\Activitylog\Contracts\Activity $activity) use ($user) {
|
||||
// 手動加入快照,因為使用 saveQuietly 所以不使用模型的 LogOptions
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'snapshot' => [
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
]);
|
||||
})
|
||||
->log('updated');
|
||||
}
|
||||
|
||||
return redirect()->route('users.index')->with('success', '使用者更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能刪除 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限刪除系統管理員');
|
||||
}
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return back()->with('error', '無法刪除超級管理員帳號');
|
||||
}
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', '無法刪除自己');
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', "使用者「{$user->name}」已刪除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換使用者啟用/停用狀態
|
||||
*/
|
||||
public function toggleActive(string $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:不能停用自己
|
||||
if ($user->id === auth()->id() && $user->is_active) {
|
||||
return back()->with('error', '無法停用自己的帳號');
|
||||
}
|
||||
|
||||
// 安全檢查:非 super-admin 不能停用 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限變更系統管理員狀態');
|
||||
}
|
||||
|
||||
$oldStatus = $user->is_active;
|
||||
$user->is_active = !$oldStatus;
|
||||
$user->save();
|
||||
|
||||
// 記錄活動
|
||||
activity()
|
||||
->performedOn($user)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => ['is_active' => $user->is_active],
|
||||
'old' => ['is_active' => $oldStatus],
|
||||
'snapshot' => [
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
|
||||
$statusText = $user->is_active ? '已啟用' : '已停用';
|
||||
return back()->with('success', "使用者「{$user->name}」{$statusText}");
|
||||
}
|
||||
}
|
||||
20
app/Modules/Core/CoreServiceProvider.php
Normal file
20
app/Modules/Core/CoreServiceProvider.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use App\Modules\Core\Services\CoreService;
|
||||
|
||||
class CoreServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(CoreServiceInterface::class, CoreService::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
20
app/Modules/Core/Models/Role.php
Normal file
20
app/Modules/Core/Models/Role.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
use Spatie\Permission\Models\Role as SpatieRole;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class Role extends SpatieRole
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
}
|
||||
61
app/Modules/Core/Models/SystemSetting.php
Normal file
61
app/Modules/Core/Models/SystemSetting.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SystemSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'value',
|
||||
'type',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* 同請求內的記憶體快取,避免重複查詢 DB
|
||||
* PHP 請求結束後自動釋放,無需額外處理失效
|
||||
*/
|
||||
protected static array $cache = [];
|
||||
|
||||
/**
|
||||
* 取得特定設定值(含記憶體快取)
|
||||
*/
|
||||
public static function getVal(string $key, $default = null)
|
||||
{
|
||||
if (array_key_exists($key, static::$cache)) {
|
||||
return static::$cache[$key];
|
||||
}
|
||||
|
||||
$setting = self::where('key', $key)->first();
|
||||
|
||||
if (!$setting) {
|
||||
static::$cache[$key] = $default;
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = $setting->value;
|
||||
|
||||
// 根據 type 進行類別轉換
|
||||
$resolved = match ($setting->type) {
|
||||
'integer', 'number' => (int) $value,
|
||||
'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
||||
'json', 'array' => json_decode($value, true),
|
||||
default => $value,
|
||||
};
|
||||
|
||||
static::$cache[$key] = $resolved;
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除記憶體快取(儲存設定後應呼叫)
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
static::$cache = [];
|
||||
}
|
||||
}
|
||||
34
app/Modules/Core/Models/Tenant.php
Normal file
34
app/Modules/Core/Models/Tenant.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
|
||||
use Stancl\Tenancy\Contracts\TenantWithDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDomains;
|
||||
|
||||
/**
|
||||
* 租戶 Model
|
||||
*
|
||||
* 代表 ERP 系統中的每一個客戶公司 (如:小小冰室、酒水客戶等)
|
||||
*
|
||||
* 自訂屬性 (存在 data JSON 欄位中,可透過 $tenant->name 存取):
|
||||
* - name: 租戶名稱 (如: 小小冰室)
|
||||
* - email: 聯絡信箱
|
||||
* - is_active: 是否啟用
|
||||
*/
|
||||
class Tenant extends BaseTenant implements TenantWithDatabase
|
||||
{
|
||||
use HasDatabase, HasDomains;
|
||||
|
||||
/**
|
||||
* 定義獨立欄位 (非 data JSON)
|
||||
* 只有 id 是獨立欄位,其他自訂屬性都存在 data JSON 中
|
||||
*/
|
||||
public static function getCustomColumns(): array
|
||||
{
|
||||
return [
|
||||
'id',
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Modules/Core/Models/User.php
Normal file
85
app/Modules/Core/Models/User.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasRoles, LogsActivity, HasApiTokens;
|
||||
|
||||
/**
|
||||
* 可批量賦值的屬性。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
/**
|
||||
* 建立模型的新工廠實例。
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Factories\Factory
|
||||
*/
|
||||
protected static function newFactory()
|
||||
{
|
||||
return \Database\Factories\UserFactory::new();
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'username',
|
||||
'password',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 序列化時應隱藏的屬性。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得應進行轉換的屬性。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'snapshot' => [
|
||||
'name' => $this->name,
|
||||
'username' => $this->username,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
app/Modules/Core/Routes/web.php
Normal file
67
app/Modules/Core/Routes/web.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Core\Controllers\Auth\LoginController;
|
||||
use App\Modules\Core\Controllers\DashboardController;
|
||||
use App\Modules\Core\Controllers\ProfileController;
|
||||
use App\Modules\Core\Controllers\RoleController;
|
||||
use App\Modules\Core\Controllers\UserController;
|
||||
use App\Modules\Core\Controllers\ActivityLogController;
|
||||
use App\Modules\Core\Controllers\SystemSettingController;
|
||||
|
||||
// 登入/登出路由
|
||||
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
||||
Route::post('/login', [LoginController::class, 'store']);
|
||||
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
// 通知
|
||||
Route::post('/notifications/read-all', [\App\Modules\Core\Controllers\NotificationController::class, 'markAllAsRead'])->name('notifications.read-all');
|
||||
Route::post('/notifications/{id}/read', [\App\Modules\Core\Controllers\NotificationController::class, 'markAsRead'])->name('notifications.read');
|
||||
Route::get('/notifications/check', [\App\Modules\Core\Controllers\NotificationController::class, 'check'])->name('notifications.check');
|
||||
|
||||
// 儀表板 - 所有登入使用者皆可存取
|
||||
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
// 使用者帳號設定
|
||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
|
||||
|
||||
// 系統管理
|
||||
Route::prefix('admin')->group(function () {
|
||||
Route::middleware('permission:roles.view')->group(function () {
|
||||
Route::get('/roles', [RoleController::class, 'index'])->name('roles.index');
|
||||
Route::middleware('permission:roles.create')->group(function () {
|
||||
Route::get('/roles/create', [RoleController::class, 'create'])->name('roles.create');
|
||||
Route::post('/roles', [RoleController::class, 'store'])->name('roles.store');
|
||||
});
|
||||
Route::get('/roles/{role}/edit', [RoleController::class, 'edit'])->middleware('permission:roles.edit')->name('roles.edit');
|
||||
Route::put('/roles/{role}', [RoleController::class, 'update'])->middleware('permission:roles.edit')->name('roles.update');
|
||||
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('permission:roles.delete')->name('roles.destroy');
|
||||
});
|
||||
|
||||
Route::middleware('permission:users.view')->group(function () {
|
||||
Route::get('/users', [UserController::class, 'index'])->name('users.index');
|
||||
Route::middleware('permission:users.create')->group(function () {
|
||||
Route::get('/users/create', [UserController::class, 'create'])->name('users.create');
|
||||
Route::post('/users', [UserController::class, 'store'])->name('users.store');
|
||||
});
|
||||
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
|
||||
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
||||
Route::patch('/users/{user}/toggle-active', [UserController::class, 'toggleActive'])->middleware('permission:users.activate')->name('users.toggle-active');
|
||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
|
||||
});
|
||||
|
||||
Route::middleware('permission:system.view_logs')->group(function () {
|
||||
Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
|
||||
});
|
||||
|
||||
Route::middleware('permission:system.settings.view')->group(function () {
|
||||
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
|
||||
Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update');
|
||||
Route::post('/settings/test-notification', [SystemSettingController::class, 'testNotification'])->name('settings.test-notification');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
55
app/Modules/Core/Services/CoreService.php
Normal file
55
app/Modules/Core/Services/CoreService.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Core\Services;
|
||||
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use App\Modules\Core\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CoreService implements CoreServiceInterface
|
||||
{
|
||||
/**
|
||||
* Get multiple users by their IDs.
|
||||
*
|
||||
* @param array $ids
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersByIds(array $ids): Collection
|
||||
{
|
||||
return User::select('id', 'name')->whereIn('id', $ids)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific user by ID.
|
||||
*
|
||||
* @param int $id
|
||||
* @return object|null
|
||||
*/
|
||||
public function getUser(int $id): ?object
|
||||
{
|
||||
return User::find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllUsers(): Collection
|
||||
{
|
||||
return User::select('id', 'name')->get();
|
||||
}
|
||||
|
||||
public function ensureSystemUserExists()
|
||||
{
|
||||
$user = User::first();
|
||||
if (!$user) {
|
||||
$user = User::create([
|
||||
'name' => '系統管理員',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
32
app/Modules/Finance/Contracts/FinanceServiceInterface.php
Normal file
32
app/Modules/Finance/Contracts/FinanceServiceInterface.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface FinanceServiceInterface
|
||||
{
|
||||
/**
|
||||
* Get accounting report data.
|
||||
*
|
||||
* @param string $start
|
||||
* @param string $end
|
||||
* @return array
|
||||
*/
|
||||
public function getAccountingReportData(string $start, string $end): array;
|
||||
|
||||
/**
|
||||
* Get all utility fees with filters.
|
||||
*
|
||||
* @param array $filters
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUtilityFees(array $filters);
|
||||
|
||||
/**
|
||||
* Get unique categories of utility fees.
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUniqueCategories(): Collection;
|
||||
}
|
||||
160
app/Modules/Finance/Controllers/AccountPayableController.php
Normal file
160
app/Modules/Finance/Controllers/AccountPayableController.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Finance\Models\AccountPayable;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
|
||||
class AccountPayableController extends Controller
|
||||
{
|
||||
protected $procurementService;
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(
|
||||
ProcurementServiceInterface $procurementService,
|
||||
InventoryServiceInterface $inventoryService
|
||||
) {
|
||||
$this->procurementService = $procurementService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = AccountPayable::with(['creator']);
|
||||
|
||||
// 關鍵字搜尋 (單號、供應商名稱)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
|
||||
// 透過 ProcurementService 查詢符合關鍵字的 Vendor IDs
|
||||
$matchedVendors = $this->procurementService->searchVendors($search);
|
||||
$vendorIds = $matchedVendors->pluck('id')->toArray();
|
||||
|
||||
$query->where(function ($q) use ($search, $vendorIds) {
|
||||
$q->where('document_number', 'like', "%{$search}%");
|
||||
if (!empty($vendorIds)) {
|
||||
$q->orWhereIn('vendor_id', $vendorIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態過濾
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// 供應商過濾
|
||||
if ($request->filled('vendor_id') && $request->vendor_id !== 'all') {
|
||||
$query->where('vendor_id', $request->vendor_id);
|
||||
}
|
||||
|
||||
// 日期區間過濾
|
||||
if ($request->filled('date_start')) {
|
||||
$query->where('due_date', '>=', $request->date_start);
|
||||
}
|
||||
if ($request->filled('date_end')) {
|
||||
$query->where('due_date', '<=', $request->date_end);
|
||||
}
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$payables = $query->latest()->paginate($perPage)->withQueryString();
|
||||
|
||||
// Manual Hydration for Vendors
|
||||
$allVendorIds = collect($payables->items())->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendorsMap = $this->procurementService->getVendorsByIds($allVendorIds)->keyBy('id');
|
||||
|
||||
$payables->getCollection()->transform(function ($item) use ($vendorsMap) {
|
||||
$item->vendor = $vendorsMap->get($item->vendor_id);
|
||||
return $item;
|
||||
});
|
||||
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
return Inertia::render('AccountPayable/Index', [
|
||||
'payables' => $payables,
|
||||
'filters' => $request->all(['search', 'status', 'vendor_id', 'date_start', 'date_end', 'per_page']),
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(AccountPayable $accountPayable)
|
||||
{
|
||||
$accountPayable->load(['creator']);
|
||||
|
||||
if ($accountPayable->vendor_id) {
|
||||
$accountPayable->vendor = $this->procurementService->getVendorsByIds([$accountPayable->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 嘗試加載來源單據資訊 (目前支援 goods_receipt)
|
||||
$sourceDocumentCode = null;
|
||||
if ($accountPayable->source_document_type === 'goods_receipt') {
|
||||
$receiptData = app(\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class)
|
||||
->getGoodsReceiptData($accountPayable->source_document_id);
|
||||
if ($receiptData) {
|
||||
$sourceDocumentCode = $receiptData['code'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('AccountPayable/Show', [
|
||||
// 將 model 轉換成 array 加入額外資訊
|
||||
'payable' => array_merge($accountPayable->toArray(), ['source_document_code' => $sourceDocumentCode]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新發票資訊
|
||||
*/
|
||||
public function updateInvoice(Request $request, AccountPayable $accountPayable)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'invoice_number' => 'nullable|string|max:50',
|
||||
'invoice_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$accountPayable->update([
|
||||
'invoice_number' => $validated['invoice_number'],
|
||||
'invoice_date' => $validated['invoice_date'],
|
||||
]);
|
||||
|
||||
return back()->with('success', '發票資訊已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 標記已付款
|
||||
*/
|
||||
public function pay(Request $request, AccountPayable $accountPayable)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'payment_method' => 'required|string|max:50',
|
||||
'paid_at' => 'required|date',
|
||||
'payment_note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($accountPayable->status === AccountPayable::STATUS_PAID) {
|
||||
return back()->with('error', '該帳款已經標記為已付款');
|
||||
}
|
||||
|
||||
$accountPayable->update([
|
||||
'status' => AccountPayable::STATUS_PAID,
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'paid_at' => $validated['paid_at'],
|
||||
'payment_note' => $validated['payment_note'],
|
||||
]);
|
||||
|
||||
return back()->with('success', '帳款已成功標記為已付款');
|
||||
}
|
||||
}
|
||||
119
app/Modules/Finance/Controllers/AccountingReportController.php
Normal file
119
app/Modules/Finance/Controllers/AccountingReportController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class AccountingReportController extends Controller
|
||||
{
|
||||
protected $financeService;
|
||||
|
||||
public function __construct(FinanceServiceInterface $financeService)
|
||||
{
|
||||
$this->financeService = $financeService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
|
||||
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
||||
|
||||
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
|
||||
$allRecords = $reportData['records'];
|
||||
|
||||
// 3. Manual Pagination
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$page = $request->input('page', 1);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$paginatedRecords = new LengthAwarePaginator(
|
||||
$allRecords->slice($offset, $perPage)->values(),
|
||||
$allRecords->count(),
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => $request->url(), 'query' => $request->query()]
|
||||
);
|
||||
|
||||
return Inertia::render('Accounting/Report', [
|
||||
'records' => $paginatedRecords,
|
||||
'summary' => $reportData['summary'],
|
||||
'filters' => [
|
||||
'date_start' => $dateStart,
|
||||
'date_end' => $dateEnd,
|
||||
'per_page' => (int)$perPage,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
|
||||
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
||||
$selectedIdsParam = $request->input('selected_ids');
|
||||
|
||||
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
|
||||
$allRecords = $reportData['records'];
|
||||
|
||||
if ($selectedIdsParam) {
|
||||
$ids = explode(',', $selectedIdsParam);
|
||||
$allRecords = $allRecords->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
$exportData = $allRecords->map(function ($record) {
|
||||
$taxAmount = (float)($record['tax_amount'] ?? 0);
|
||||
$totalAmount = (float)($record['amount'] ?? 0);
|
||||
$untaxedAmount = $totalAmount - $taxAmount;
|
||||
|
||||
return [
|
||||
$record['date'],
|
||||
$record['source'],
|
||||
$record['category'],
|
||||
$record['item'],
|
||||
$record['reference'],
|
||||
$record['invoice_date'] ?? '-',
|
||||
$record['invoice_number'] ?? '-',
|
||||
$untaxedAmount,
|
||||
$taxAmount,
|
||||
$totalAmount,
|
||||
$record['payment_method'] ?? '-',
|
||||
$record['payment_note'] ?? '-',
|
||||
$record['remarks'] ?? '-',
|
||||
$record['status'] ?? '-',
|
||||
];
|
||||
});
|
||||
|
||||
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$callback = function () use ($exportData) {
|
||||
$file = fopen('php://output', 'w');
|
||||
// BOM for Excel compatibility with UTF-8
|
||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
fputcsv($file, [
|
||||
'日期', '來源', '類別', '項目', '參考單號',
|
||||
'發票日期', '發票號碼', '未稅金額', '稅額', '總金額',
|
||||
'付款方式', '付款備註', '內部備註', '狀態'
|
||||
]);
|
||||
|
||||
foreach ($exportData as $row) {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
}
|
||||
186
app/Modules/Finance/Controllers/UtilityFeeController.php
Normal file
186
app/Modules/Finance/Controllers/UtilityFeeController.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
protected $financeService;
|
||||
|
||||
public function __construct(FinanceServiceInterface $financeService)
|
||||
{
|
||||
$this->financeService = $financeService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']);
|
||||
|
||||
$fees = $this->financeService->getUtilityFees($filters)->withQueryString();
|
||||
$availableCategories = $this->financeService->getUniqueCategories();
|
||||
|
||||
return Inertia::render('UtilityFee/Index', [
|
||||
'fees' => $fees,
|
||||
'availableCategories' => $availableCategories,
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'transaction_date' => 'nullable|date',
|
||||
'due_date' => 'required|date',
|
||||
'category' => 'required|string|max:255',
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'invoice_number' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['payment_status'] = $this->determineStatus($validated);
|
||||
|
||||
$fee = UtilityFee::create($validated);
|
||||
|
||||
activity()
|
||||
->performedOn($fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('created')
|
||||
->log('created');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function update(Request $request, UtilityFee $utility_fee)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'transaction_date' => 'nullable|date',
|
||||
'due_date' => 'required|date',
|
||||
'category' => 'required|string|max:255',
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'invoice_number' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['payment_status'] = $this->determineStatus($validated);
|
||||
|
||||
$utility_fee->update($validated);
|
||||
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->log('updated');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定繳費狀態
|
||||
*/
|
||||
private function determineStatus(array $data): string
|
||||
{
|
||||
if (!empty($data['transaction_date'])) {
|
||||
return UtilityFee::STATUS_PAID;
|
||||
}
|
||||
|
||||
if (!empty($data['due_date']) && now()->startOfDay()->gt(\Illuminate\Support\Carbon::parse($data['due_date']))) {
|
||||
return UtilityFee::STATUS_OVERDUE;
|
||||
}
|
||||
|
||||
return UtilityFee::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function destroy(UtilityFee $utility_fee)
|
||||
{
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->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' => '刪除成功']);
|
||||
}
|
||||
}
|
||||
23
app/Modules/Finance/FinanceServiceProvider.php
Normal file
23
app/Modules/Finance/FinanceServiceProvider.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||
use App\Modules\Finance\Services\FinanceService;
|
||||
|
||||
class FinanceServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(FinanceServiceInterface::class, FinanceService::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\Event::listen(
|
||||
\App\Modules\Inventory\Events\GoodsReceiptApprovedEvent::class,
|
||||
\App\Modules\Finance\Listeners\CreateAccountPayableFromGoodsReceipt::class
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Listeners;
|
||||
|
||||
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
|
||||
use App\Modules\Finance\Services\AccountPayableService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CreateAccountPayableFromGoodsReceipt
|
||||
{
|
||||
protected AccountPayableService $accountPayableService;
|
||||
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct(AccountPayableService $accountPayableService)
|
||||
{
|
||||
$this->accountPayableService = $accountPayableService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(GoodsReceiptApprovedEvent $event): void
|
||||
{
|
||||
try {
|
||||
// 目前使用系統預設 User ID 或 0 作為自動生成的建立者,若能從 event 取得更好
|
||||
$userId = auth()->id() ?? 1; // 假設 1 為系統管理員或預設使用者
|
||||
|
||||
$this->accountPayableService->createFromGoodsReceipt($event->goodsReceiptId, $userId);
|
||||
|
||||
Log::info("已成功為進貨單 ID: {$event->goodsReceiptId} 建立應付帳款");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("建立應付帳款失敗 (進貨單 ID: {$event->goodsReceiptId}): " . $e->getMessage());
|
||||
// 根據需求決定是否拋出 exception 或只記錄 log
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/Modules/Finance/Models/AccountPayable.php
Normal file
54
app/Modules/Finance/Models/AccountPayable.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AccountPayable extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PARTIALLY_PAID = 'partially_paid';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'source_document_type',
|
||||
'source_document_id',
|
||||
'document_number',
|
||||
'total_amount',
|
||||
'tax_amount',
|
||||
'status',
|
||||
'due_date',
|
||||
'invoice_number',
|
||||
'invoice_date',
|
||||
'paid_at',
|
||||
'payment_method',
|
||||
'payment_note',
|
||||
'remarks',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'due_date' => 'date',
|
||||
'invoice_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
// vendor 關聯移至 service (跨模組)
|
||||
|
||||
/**
|
||||
* 關聯:建立者
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Core\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
53
app/Modules/Finance/Models/UtilityFee.php
Normal file
53
app/Modules/Finance/Models/UtilityFee.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UtilityFee extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 此公共事業費的附件
|
||||
*/
|
||||
public function attachments()
|
||||
{
|
||||
return $this->hasMany(UtilityFeeAttachment::class);
|
||||
}
|
||||
|
||||
// 狀態常數
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PAID = 'paid';
|
||||
const STATUS_OVERDUE = 'overdue';
|
||||
|
||||
protected $fillable = [
|
||||
'transaction_date',
|
||||
'due_date',
|
||||
'category',
|
||||
'amount',
|
||||
'payment_status',
|
||||
'invoice_number',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'transaction_date' => 'date:Y-m-d',
|
||||
'due_date' => 'date:Y-m-d',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$snapshot = [
|
||||
'transaction_date' => $this->transaction_date?->format('Y-m-d'),
|
||||
'due_date' => $this->due_date?->format('Y-m-d'),
|
||||
'category' => $this->category,
|
||||
'amount' => $this->amount,
|
||||
'payment_status' => $this->payment_status,
|
||||
'invoice_number' => $this->invoice_number,
|
||||
];
|
||||
$activity->properties = $activity->properties->put('snapshot', $snapshot);
|
||||
}
|
||||
}
|
||||
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UtilityFeeAttachment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'utility_fee_id',
|
||||
'file_path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
];
|
||||
|
||||
protected $appends = ['url'];
|
||||
|
||||
/**
|
||||
* 附件所属的公共事業費
|
||||
*/
|
||||
public function utilityFee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UtilityFee::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取附件的全路徑 URL
|
||||
*/
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return tenant_asset($this->file_path);
|
||||
}
|
||||
}
|
||||
50
app/Modules/Finance/Routes/web.php
Normal file
50
app/Modules/Finance/Routes/web.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Finance\Controllers\UtilityFeeController;
|
||||
use App\Modules\Finance\Controllers\AccountingReportController;
|
||||
|
||||
use App\Modules\Finance\Controllers\AccountPayableController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
// 應付帳款
|
||||
Route::group(['prefix' => 'finance'], function () {
|
||||
Route::middleware('permission:account_payables.view')->group(function () {
|
||||
Route::get('/account-payables', [AccountPayableController::class, 'index'])->name('account-payables.index');
|
||||
Route::get('/account-payables/{accountPayable}', [AccountPayableController::class, 'show'])->name('account-payables.show');
|
||||
});
|
||||
Route::middleware('permission:account_payables.edit')->group(function () {
|
||||
Route::post('/account-payables/{accountPayable}/invoice', [AccountPayableController::class, 'updateInvoice'])->name('account-payables.invoice');
|
||||
});
|
||||
Route::middleware('permission:account_payables.pay')->group(function () {
|
||||
Route::post('/account-payables/{accountPayable}/pay', [AccountPayableController::class, 'pay'])->name('account-payables.pay');
|
||||
});
|
||||
});
|
||||
|
||||
// 公共事業費管理
|
||||
Route::middleware('permission:utility_fees.view')->group(function () {
|
||||
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
|
||||
});
|
||||
Route::middleware('permission:utility_fees.create')->group(function () {
|
||||
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
// 會計報表
|
||||
Route::middleware('permission:accounting.view')->prefix('accounting-report')->group(function () {
|
||||
Route::get('/', [AccountingReportController::class, 'index'])->name('accounting.report');
|
||||
Route::get('/export', [AccountingReportController::class, 'export'])
|
||||
->middleware('permission:accounting.export')
|
||||
->name('accounting.export');
|
||||
});
|
||||
});
|
||||
86
app/Modules/Finance/Services/AccountPayableService.php
Normal file
86
app/Modules/Finance/Services/AccountPayableService.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Services;
|
||||
|
||||
use App\Modules\Finance\Models\AccountPayable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface;
|
||||
|
||||
class AccountPayableService
|
||||
{
|
||||
protected GoodsReceiptServiceInterface $goodsReceiptService;
|
||||
|
||||
public function __construct(GoodsReceiptServiceInterface $goodsReceiptService)
|
||||
{
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據進貨單建立應付帳款
|
||||
*
|
||||
* @param int $goodsReceiptId
|
||||
* @param int $userId 執行操作的使用者 ID
|
||||
* @return AccountPayable
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createFromGoodsReceipt(int $goodsReceiptId, int $userId): AccountPayable
|
||||
{
|
||||
// 透過 Contract 取得 Inventory 模組的資料,避免直接依賴 Model
|
||||
$receiptData = $this->goodsReceiptService->getGoodsReceiptData($goodsReceiptId);
|
||||
|
||||
if (!$receiptData) {
|
||||
throw new \Exception("找不到對應的進貨單資料 (ID: {$goodsReceiptId})");
|
||||
}
|
||||
|
||||
// 檢查是否已經建立過(密等性)
|
||||
$existingAp = AccountPayable::where('source_document_type', 'goods_receipt')
|
||||
->where('source_document_id', $goodsReceiptId)
|
||||
->first();
|
||||
|
||||
if ($existingAp) {
|
||||
return $existingAp;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($receiptData, $userId) {
|
||||
$ap = AccountPayable::create([
|
||||
'vendor_id' => $receiptData['vendor_id'],
|
||||
'source_document_type' => 'goods_receipt',
|
||||
'source_document_id' => $receiptData['id'],
|
||||
'document_number' => $this->generateApNumber(),
|
||||
'total_amount' => collect($receiptData['items'] ?? [])->sum('total_amount'),
|
||||
'tax_amount' => 0, // 假設後續會實作稅額計算,目前預設為 0
|
||||
'status' => AccountPayable::STATUS_PENDING,
|
||||
// 設定應付日期,預設為進貨後天數 (由系統設定決定,預設 30 天)
|
||||
'due_date' => now()->addDays(\App\Modules\Core\Models\SystemSetting::getVal('finance.ap_payment_days', 30))->toDateString(),
|
||||
'created_by' => $userId,
|
||||
'remarks' => "由進貨單 {$receiptData['code']} 自動生成",
|
||||
]);
|
||||
|
||||
return $ap;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生應付帳款單號
|
||||
*/
|
||||
protected function generateApNumber(): string
|
||||
{
|
||||
$prefix = 'AP-' . date('Ymd') . '-';
|
||||
$lastPrefix = "{$prefix}%";
|
||||
|
||||
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
||||
->orderBy('document_number', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$latest) {
|
||||
return $prefix . '01';
|
||||
}
|
||||
|
||||
$parts = explode('-', $latest->document_number);
|
||||
$lastNumber = intval(end($parts));
|
||||
$newNumber = str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT);
|
||||
|
||||
return $prefix . $newNumber;
|
||||
}
|
||||
}
|
||||
141
app/Modules/Finance/Services/FinanceService.php
Normal file
141
app/Modules/Finance/Services/FinanceService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Services;
|
||||
|
||||
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||
use App\Modules\Finance\Models\UtilityFee;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class FinanceService implements FinanceServiceInterface
|
||||
{
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(ProcurementServiceInterface $procurementService)
|
||||
{
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
public function getAccountingReportData(string $start, string $end): array
|
||||
{
|
||||
// 1. 獲取應付帳款資料 (已付款)
|
||||
$accountPayables = \App\Modules\Finance\Models\AccountPayable::where('status', \App\Modules\Finance\Models\AccountPayable::STATUS_PAID)
|
||||
->whereNotNull('paid_at')
|
||||
->whereBetween('paid_at', [$start, $end])
|
||||
->get();
|
||||
|
||||
// 取得供應商資料 (Manual Hydration)
|
||||
$vendorIds = $accountPayables->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendorsMap = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
|
||||
|
||||
// 付款方式對映
|
||||
$paymentMethodMap = [
|
||||
'cash' => '現金',
|
||||
'bank_transfer' => '銀行轉帳',
|
||||
'check' => '支票',
|
||||
'credit_card' => '信用卡',
|
||||
];
|
||||
|
||||
$payableRecords = $accountPayables->map(function ($ap) use ($vendorsMap, $paymentMethodMap) {
|
||||
$vendorName = isset($vendorsMap[$ap->vendor_id]) ? $vendorsMap[$ap->vendor_id]->name : '未知廠商';
|
||||
$mappedPaymentMethod = $paymentMethodMap[$ap->payment_method] ?? $ap->payment_method;
|
||||
return [
|
||||
'id' => 'AP-' . $ap->id,
|
||||
'date' => Carbon::parse($ap->paid_at)->timezone(config('app.timezone'))->toDateString(),
|
||||
'source' => '應付帳款',
|
||||
'category' => '進貨支出',
|
||||
'item' => $vendorName,
|
||||
'reference' => $ap->document_number,
|
||||
'invoice_date' => $ap->invoice_date ? $ap->invoice_date->format('Y-m-d') : null,
|
||||
'invoice_number' => $ap->invoice_number,
|
||||
'amount' => (float)$ap->total_amount,
|
||||
'tax_amount' => (float)$ap->tax_amount,
|
||||
'status' => $ap->status,
|
||||
'payment_method' => $mappedPaymentMethod,
|
||||
'payment_note' => $ap->payment_note,
|
||||
'remarks' => $ap->remarks,
|
||||
];
|
||||
});
|
||||
|
||||
// 2. 獲取公共事業費 (已繳費)
|
||||
$utilityFees = UtilityFee::where('payment_status', UtilityFee::STATUS_PAID)
|
||||
->whereBetween('transaction_date', [$start, $end])
|
||||
->get()
|
||||
->map(function ($fee) {
|
||||
return [
|
||||
'id' => 'UF-' . $fee->id,
|
||||
'date' => $fee->transaction_date->format('Y-m-d'),
|
||||
'source' => '公共事業費',
|
||||
'category' => $fee->category,
|
||||
'item' => $fee->description ?: $fee->category,
|
||||
'reference' => '-',
|
||||
'invoice_date' => null,
|
||||
'invoice_number' => $fee->invoice_number,
|
||||
'amount' => (float)$fee->amount,
|
||||
'tax_amount' => 0.0,
|
||||
'status' => $fee->payment_status,
|
||||
'payment_method' => null,
|
||||
'payment_note' => null,
|
||||
'remarks' => $fee->description,
|
||||
];
|
||||
});
|
||||
|
||||
$allRecords = $payableRecords->concat($utilityFees)
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
return [
|
||||
'records' => $allRecords,
|
||||
'summary' => [
|
||||
'total_amount' => $allRecords->sum('amount'),
|
||||
'payable_total' => $payableRecords->sum('amount'),
|
||||
'utility_total' => $utilityFees->sum('amount'),
|
||||
'record_count' => $allRecords->count(),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getUtilityFees(array $filters)
|
||||
{
|
||||
$query = UtilityFee::withCount('attachments');
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('category', 'like', "%{$search}%")
|
||||
->orWhere('invoice_number', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($filters['category']) && $filters['category'] !== 'all') {
|
||||
$query->where('category', $filters['category']);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_start'])) {
|
||||
$query->where('transaction_date', '>=', $filters['date_start']);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_end'])) {
|
||||
$query->where('transaction_date', '<=', $filters['date_end']);
|
||||
}
|
||||
|
||||
$sortField = $filters['sort_field'] ?? 'created_at';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) ($filters['per_page'] ?? $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getUniqueCategories(): Collection
|
||||
{
|
||||
return UtilityFee::distinct()->pluck('category');
|
||||
}
|
||||
}
|
||||
161
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
161
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Actions;
|
||||
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use App\Modules\Integration\Models\SalesOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SyncOrderAction
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $productService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProductServiceInterface $productService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->productService = $productService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行訂單同步
|
||||
*
|
||||
* @param array $data
|
||||
* @return array 包含 orders 建立結果的資訊
|
||||
* @throws ValidationException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function execute(array $data)
|
||||
{
|
||||
$externalOrderId = $data['external_order_id'];
|
||||
|
||||
// 使用 Cache::lock 防護高併發,鎖定該訂單號 10 秒
|
||||
// 此處需要 cache store 支援鎖 (如 memcached, dynamodb, redis, database, file, array)
|
||||
// Laravel 預設的 file/redis 都支援。若無法取得鎖,表示有另一個相同的請求正在處理
|
||||
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
|
||||
|
||||
if (!$lock->get()) {
|
||||
throw ValidationException::withMessages([
|
||||
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
|
||||
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
|
||||
if ($existingOrder) {
|
||||
return [
|
||||
'status' => 'exists',
|
||||
'message' => 'Order already exists',
|
||||
'order_id' => $existingOrder->id,
|
||||
];
|
||||
}
|
||||
|
||||
// --- 預檢 (Pre-flight check) N+1 優化 ---
|
||||
$items = $data['items'];
|
||||
$posProductIds = array_column($items, 'pos_product_id');
|
||||
|
||||
// 一次性查出所有相關的 Product
|
||||
$products = $this->productService->findByExternalPosIds($posProductIds)->keyBy('external_pos_id');
|
||||
|
||||
$missingIds = [];
|
||||
foreach ($posProductIds as $id) {
|
||||
if (!$products->has($id)) {
|
||||
$missingIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingIds)) {
|
||||
// 回報所有缺漏的 ID
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["The following products are not found: " . implode(', ', $missingIds) . ". Please sync products first."]
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 執行寫入交易 ---
|
||||
$result = DB::transaction(function () use ($data, $items, $products) {
|
||||
// 1. 建立訂單
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $data['external_order_id'],
|
||||
'status' => 'completed',
|
||||
'payment_method' => $data['payment_method'] ?? 'cash',
|
||||
'total_amount' => 0,
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => $data['source'] ?? 'pos',
|
||||
'source_label' => $data['source_label'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. 查找倉庫
|
||||
$warehouseCode = $data['warehouse_code'];
|
||||
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||
|
||||
if ($warehouses->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||
]);
|
||||
}
|
||||
$warehouseId = $warehouses->first()->id;
|
||||
|
||||
$totalAmount = 0;
|
||||
|
||||
// 3. 處理訂單明細
|
||||
$orderItemsData = [];
|
||||
foreach ($items as $itemData) {
|
||||
$product = $products->get($itemData['pos_product_id']);
|
||||
|
||||
$qty = $itemData['qty'];
|
||||
$price = $itemData['price'];
|
||||
$lineTotal = $qty * $price;
|
||||
$totalAmount += $lineTotal;
|
||||
|
||||
$orderItemsData[] = [
|
||||
'sales_order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name,
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $lineTotal,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// 4. 扣除庫存(強制模式,允許負庫存)
|
||||
$this->inventoryService->decreaseStock(
|
||||
$product->id,
|
||||
$warehouseId,
|
||||
$qty,
|
||||
"POS Order: " . $order->external_order_id,
|
||||
true,
|
||||
null,
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
$order->id
|
||||
);
|
||||
}
|
||||
|
||||
// Batch insert order items
|
||||
SalesOrderItem::insert($orderItemsData);
|
||||
|
||||
$order->update(['total_amount' => $totalAmount]);
|
||||
|
||||
return [
|
||||
'status' => 'created',
|
||||
'message' => 'Order synced and stock deducted successfully',
|
||||
'order_id' => $order->id,
|
||||
];
|
||||
});
|
||||
|
||||
return $result;
|
||||
} finally {
|
||||
// 無論成功失敗,最後釋放鎖定
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
157
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
157
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Actions;
|
||||
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use App\Modules\Integration\Models\SalesOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SyncVendingOrderAction
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $productService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProductServiceInterface $productService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->productService = $productService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行販賣機訂單同步
|
||||
*
|
||||
* @param array $data
|
||||
* @return array 包含訂單建立結果的資訊
|
||||
* @throws ValidationException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function execute(array $data)
|
||||
{
|
||||
$externalOrderId = $data['external_order_id'];
|
||||
|
||||
// 使用 Cache::lock 防護高併發
|
||||
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
|
||||
|
||||
if (!$lock->get()) {
|
||||
throw ValidationException::withMessages([
|
||||
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
|
||||
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
|
||||
if ($existingOrder) {
|
||||
return [
|
||||
'status' => 'exists',
|
||||
'message' => 'Order already exists',
|
||||
'order_id' => $existingOrder->id,
|
||||
];
|
||||
}
|
||||
|
||||
// --- 預檢:以 ERP 商品代碼查詢 ---
|
||||
$items = $data['items'];
|
||||
$productCodes = array_column($items, 'product_code');
|
||||
|
||||
// 一次性查出所有相關的 Product(以 code 查詢)
|
||||
$products = $this->productService->findByCodes($productCodes)->keyBy('code');
|
||||
|
||||
$missingCodes = [];
|
||||
foreach ($productCodes as $code) {
|
||||
if (!$products->has($code)) {
|
||||
$missingCodes[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingCodes)) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["The following products are not found by code: " . implode(', ', $missingCodes) . ". Please ensure these products exist in the system."]
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 執行寫入交易 ---
|
||||
$result = DB::transaction(function () use ($data, $items, $products) {
|
||||
// 1. 建立訂單
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $data['external_order_id'],
|
||||
'status' => 'completed',
|
||||
'payment_method' => $data['payment_method'] ?? 'electronic',
|
||||
'total_amount' => 0,
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => 'vending',
|
||||
'source_label' => $data['machine_id'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. 查找倉庫
|
||||
$warehouseCode = $data['warehouse_code'];
|
||||
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||
|
||||
if ($warehouses->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||
]);
|
||||
}
|
||||
$warehouseId = $warehouses->first()->id;
|
||||
|
||||
$totalAmount = 0;
|
||||
|
||||
// 3. 處理訂單明細
|
||||
$orderItemsData = [];
|
||||
foreach ($items as $itemData) {
|
||||
$product = $products->get($itemData['product_code']);
|
||||
|
||||
$qty = $itemData['qty'];
|
||||
$price = $itemData['price'];
|
||||
$lineTotal = $qty * $price;
|
||||
$totalAmount += $lineTotal;
|
||||
|
||||
$orderItemsData[] = [
|
||||
'sales_order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name,
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $lineTotal,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// 4. 扣除庫存(強制模式,允許負庫存)
|
||||
$this->inventoryService->decreaseStock(
|
||||
$product->id,
|
||||
$warehouseId,
|
||||
$qty,
|
||||
"Vending Order: " . $order->external_order_id,
|
||||
true,
|
||||
null,
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
$order->id
|
||||
);
|
||||
}
|
||||
|
||||
// Batch insert order items
|
||||
SalesOrderItem::insert($orderItemsData);
|
||||
|
||||
$order->update(['total_amount' => $totalAmount]);
|
||||
|
||||
return [
|
||||
'status' => 'created',
|
||||
'message' => 'Vending order synced and stock deducted successfully',
|
||||
'order_id' => $order->id,
|
||||
];
|
||||
});
|
||||
|
||||
return $result;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class InventorySyncController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供外部 POS 查詢指定倉庫的商品庫存餘額
|
||||
*
|
||||
* @param string $warehouseCode
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(string $warehouseCode): JsonResponse
|
||||
{
|
||||
// 透過 Service 調用跨模組庫存查詢功能
|
||||
$inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode);
|
||||
|
||||
// 若回傳 null,表示尋無此倉庫代碼
|
||||
if (is_null($inventoryData)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "Warehouse with code '{$warehouseCode}' not found.",
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 以 JSON 格式回傳組合好的商品庫存列表
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'warehouse_code' => $warehouseCode,
|
||||
'data' => $inventoryData->map(function ($item) {
|
||||
return [
|
||||
'external_pos_id' => $item->external_pos_id,
|
||||
'product_code' => $item->product_code,
|
||||
'product_name' => $item->product_name,
|
||||
'quantity' => (float) $item->total_quantity,
|
||||
];
|
||||
})
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
60
app/Modules/Integration/Controllers/OrderSyncController.php
Normal file
60
app/Modules/Integration/Controllers/OrderSyncController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Integration\Requests\SyncOrderRequest;
|
||||
use App\Modules\Integration\Actions\SyncOrderAction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OrderSyncController extends Controller
|
||||
{
|
||||
protected $syncOrderAction;
|
||||
|
||||
public function __construct(SyncOrderAction $syncOrderAction)
|
||||
{
|
||||
$this->syncOrderAction = $syncOrderAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收並同步外部交易訂單
|
||||
*
|
||||
* @param SyncOrderRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(SyncOrderRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
// 所有驗證皆已透過 SyncOrderRequest 自動處理
|
||||
// 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動)
|
||||
$result = $this->syncOrderAction->execute($request->validated());
|
||||
|
||||
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||
|
||||
return response()->json([
|
||||
'message' => $result['message'],
|
||||
'order_id' => $result['order_id'] ?? null,
|
||||
], $statusCode);
|
||||
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
// 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時)
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 系統層級的錯誤
|
||||
Log::error('Order Sync Failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'payload' => $request->all()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sync failed: An unexpected error occurred.'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProductSyncController extends Controller
|
||||
{
|
||||
protected $productService;
|
||||
|
||||
public function __construct(ProductServiceInterface $productService)
|
||||
{
|
||||
$this->productService = $productService;
|
||||
}
|
||||
|
||||
public function upsert(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'external_pos_id' => 'required|string|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'unit' => 'nullable|string|max:100',
|
||||
'brand' => 'nullable|string|max:100',
|
||||
'specification' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'member_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'wholesale_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'updated_at' => 'nullable|date',
|
||||
]);
|
||||
|
||||
try {
|
||||
$product = $this->productService->upsertFromPos($request->all());
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Product synced successfully',
|
||||
'data' => [
|
||||
'id' => $product->id,
|
||||
'external_pos_id' => $product->external_pos_id,
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
||||
return response()->json([
|
||||
'message' => 'Sync failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
58
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SalesOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示銷售訂單列表
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = SalesOrder::query();
|
||||
|
||||
// 搜尋篩選 (外部訂單號)
|
||||
if ($request->filled('search')) {
|
||||
$query->where('external_order_id', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
// 來源篩選
|
||||
if ($request->filled('source')) {
|
||||
$query->where('source', $request->source);
|
||||
}
|
||||
|
||||
// 排序
|
||||
$query->orderBy('sold_at', 'desc');
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$orders = $query->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('Integration/SalesOrders/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'per_page', 'source']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示單一銷售訂單詳情
|
||||
*/
|
||||
public function show(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->load(['items']);
|
||||
|
||||
return Inertia::render('Integration/SalesOrders/Show', [
|
||||
'order' => $salesOrder,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Integration\Requests\SyncVendingOrderRequest;
|
||||
use App\Modules\Integration\Actions\SyncVendingOrderAction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VendingOrderSyncController extends Controller
|
||||
{
|
||||
protected $syncVendingOrderAction;
|
||||
|
||||
public function __construct(SyncVendingOrderAction $syncVendingOrderAction)
|
||||
{
|
||||
$this->syncVendingOrderAction = $syncVendingOrderAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收並同步販賣機交易訂單
|
||||
*
|
||||
* @param SyncVendingOrderRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(SyncVendingOrderRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->syncVendingOrderAction->execute($request->validated());
|
||||
|
||||
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||
|
||||
return response()->json([
|
||||
'message' => $result['message'],
|
||||
'order_id' => $result['order_id'] ?? null,
|
||||
], $statusCode);
|
||||
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Vending Order Sync Failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'payload' => $request->all()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sync failed: An unexpected error occurred.'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Modules/Integration/IntegrationServiceProvider.php
Normal file
33
app/Modules/Integration/IntegrationServiceProvider.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Integration\Middleware\TenantIdentificationMiddleware;
|
||||
|
||||
class IntegrationServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
||||
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
||||
|
||||
// 註冊 Middleware 別名
|
||||
Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class);
|
||||
|
||||
// 定義 Integration API 速率限制(每分鐘 60 次,依 Token 使用者識別)
|
||||
RateLimiter::for('integration', function (Request $request) {
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantIdentificationMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 1. Check for X-Tenant-Domain header
|
||||
$domain = $request->header('X-Tenant-Domain');
|
||||
|
||||
if (! $domain) {
|
||||
return response()->json([
|
||||
'message' => 'Missing X-Tenant-Domain header.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 2. Find Tenant by domain
|
||||
// Assuming domains are stored in 'domains' table and linked to tenants
|
||||
// Or using Stancl's tenant finder.
|
||||
// Stancl Tenancy usually finds by domain automatically for web routes, but for API
|
||||
// we are doing manual identification because we might not be using subdomains for API calls (or maybe we are).
|
||||
// If the API endpoint is centrally hosted (e.g. api.star-erp.com/v1/...), we need this header.
|
||||
|
||||
// Let's try to initialize tenancy manually.
|
||||
// We need to find the tenant model that has this domain.
|
||||
try {
|
||||
$tenant = \App\Modules\Core\Models\Tenant::whereHas('domains', function ($query) use ($domain) {
|
||||
$query->where('domain', $domain);
|
||||
})->first();
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'message' => 'Tenant not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
Tenancy::initialize($tenant);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Tenant initialization failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
33
app/Modules/Integration/Models/SalesOrder.php
Normal file
33
app/Modules/Integration/Models/SalesOrder.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SalesOrder extends Model
|
||||
{
|
||||
protected $table = 'sales_orders';
|
||||
|
||||
protected $fillable = [
|
||||
'external_order_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'total_amount',
|
||||
'sold_at',
|
||||
'raw_payload',
|
||||
'source',
|
||||
'source_label',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sold_at' => 'datetime',
|
||||
'raw_payload' => 'array',
|
||||
'total_amount' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesOrderItem::class);
|
||||
}
|
||||
}
|
||||
31
app/Modules/Integration/Models/SalesOrderItem.php
Normal file
31
app/Modules/Integration/Models/SalesOrderItem.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SalesOrderItem extends Model
|
||||
{
|
||||
protected $table = 'sales_order_items';
|
||||
|
||||
protected $fillable = [
|
||||
'sales_order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'quantity',
|
||||
'price',
|
||||
'total',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:4',
|
||||
'price' => 'decimal:4',
|
||||
'total' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesOrder::class, 'sales_order_id');
|
||||
}
|
||||
}
|
||||
35
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
35
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SyncOrderRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'external_order_id' => 'required|string',
|
||||
'warehouse_code' => 'required|string',
|
||||
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
||||
'sold_at' => 'nullable|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.pos_product_id' => 'required|string',
|
||||
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||
'items.*.price' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
36
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SyncVendingOrderRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 販賣機訂單同步的驗證規則
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'external_order_id' => 'required|string',
|
||||
'machine_id' => 'nullable|string',
|
||||
'warehouse_code' => 'required|string',
|
||||
'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other',
|
||||
'sold_at' => 'nullable|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_code' => 'required|string', // 使用 ERP 商品代碼
|
||||
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||
'items.*.price' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Modules/Integration/Routes/api.php
Normal file
16
app/Modules/Integration/Routes/api.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Integration\Controllers\ProductSyncController;
|
||||
use App\Modules\Integration\Controllers\OrderSyncController;
|
||||
use App\Modules\Integration\Controllers\VendingOrderSyncController;
|
||||
use App\Modules\Integration\Controllers\InventorySyncController;
|
||||
|
||||
Route::prefix('api/v1/integration')
|
||||
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
|
||||
->group(function () {
|
||||
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
||||
Route::post('orders', [OrderSyncController::class, 'store']);
|
||||
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
|
||||
Route::get('inventory/{warehouse_code}', [InventorySyncController::class, 'show']);
|
||||
});
|
||||
13
app/Modules/Integration/Routes/web.php
Normal file
13
app/Modules/Integration/Routes/web.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Modules\Integration\Controllers\SalesOrderController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['web', 'auth', 'verified'])->group(function () {
|
||||
Route::prefix('integration')->name('integration.')->group(function () {
|
||||
Route::middleware('permission:sales_orders.view')->group(function () {
|
||||
Route::get('sales-orders', [SalesOrderController::class, 'index'])->name('sales-orders.index');
|
||||
Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Contracts;
|
||||
|
||||
interface GoodsReceiptServiceInterface
|
||||
{
|
||||
/**
|
||||
* 獲取指定的進貨單資訊
|
||||
*
|
||||
* @param int $goodsReceiptId
|
||||
* @return array|null 返回進貨單的純陣列資料,若找不到則回傳 null
|
||||
*/
|
||||
public function getGoodsReceiptData(int $goodsReceiptId): ?array;
|
||||
}
|
||||
187
app/Modules/Inventory/Contracts/InventoryServiceInterface.php
Normal file
187
app/Modules/Inventory/Contracts/InventoryServiceInterface.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Contracts;
|
||||
|
||||
interface InventoryServiceInterface
|
||||
{
|
||||
/**
|
||||
* Check if a product has sufficient stock in a specific warehouse.
|
||||
*
|
||||
* @param int $productId
|
||||
* @param int $warehouseId
|
||||
* @param float $quantity
|
||||
* @return bool
|
||||
*/
|
||||
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
|
||||
|
||||
/**
|
||||
* @param int $productId
|
||||
* @param int $warehouseId
|
||||
* @param float $quantity
|
||||
* @param string|null $reason
|
||||
* @param bool $force
|
||||
* @param string|null $slot
|
||||
* @return void
|
||||
*/
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void;
|
||||
|
||||
/**
|
||||
* Get all active warehouses.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getAllWarehouses();
|
||||
|
||||
/**
|
||||
* Get multiple products by their IDs.
|
||||
*
|
||||
* @param array $ids
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getProductsByIds(array $ids);
|
||||
|
||||
/**
|
||||
* Get multiple warehouses by their codes.
|
||||
*
|
||||
* @param array $codes
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getWarehousesByCodes(array $codes);
|
||||
|
||||
/**
|
||||
* Search products by name.
|
||||
*
|
||||
* @param string $name
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getProductsByName(string $name);
|
||||
|
||||
/**
|
||||
* Get a specific product by ID.
|
||||
*
|
||||
* @param int $id
|
||||
* @return object|null
|
||||
*/
|
||||
public function getProduct(int $id);
|
||||
|
||||
/**
|
||||
* Get a specific warehouse by ID.
|
||||
*
|
||||
* @param int $id
|
||||
* @return object|null
|
||||
*/
|
||||
public function getWarehouse(int $id);
|
||||
|
||||
/**
|
||||
* Get all available inventories in a specific warehouse.
|
||||
*
|
||||
* @param int $warehouseId
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getInventoriesByWarehouse(int $warehouseId);
|
||||
|
||||
/**
|
||||
* Get all products.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getAllProducts();
|
||||
|
||||
/**
|
||||
* Get all units.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getUnits();
|
||||
|
||||
/**
|
||||
* Create a new inventory record (e.g., for finished goods).
|
||||
*
|
||||
* @param array $data
|
||||
* @return object
|
||||
*/
|
||||
public function createInventoryRecord(array $data);
|
||||
|
||||
/**
|
||||
* Decrease quantity of a specific inventory record.
|
||||
*
|
||||
* @param int $inventoryId
|
||||
* @param float $quantity
|
||||
* @param string|null $reason
|
||||
* @param string|null $referenceType
|
||||
* @param int|string|null $referenceId
|
||||
* @return void
|
||||
*/
|
||||
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
|
||||
|
||||
/**
|
||||
* Find a specific inventory record by warehouse, product and batch.
|
||||
*
|
||||
* @param int $warehouseId
|
||||
* @param int $productId
|
||||
* @param string|null $batchNumber
|
||||
* @return object|null
|
||||
*/
|
||||
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber);
|
||||
|
||||
/**
|
||||
* 取得即時庫存查詢資料(含統計卡片 + 分頁明細)。
|
||||
*
|
||||
* @param array $filters 篩選條件
|
||||
* @param int $perPage 每頁筆數
|
||||
* @return array
|
||||
*/
|
||||
public function getStockQueryData(array $filters = [], ?int $perPage = null): array;
|
||||
|
||||
/**
|
||||
* Get statistics for the dashboard.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardStats(): array;
|
||||
|
||||
/**
|
||||
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
|
||||
*
|
||||
* @param string $warehouseName
|
||||
* @return object
|
||||
*/
|
||||
public function findOrCreateWarehouseByName(string $warehouseName);
|
||||
|
||||
/**
|
||||
* Get top inventory value for dashboard.
|
||||
*/
|
||||
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Get items expiring soon for dashboard.
|
||||
*/
|
||||
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Get inventory summary (group by product) for a specific warehouse code
|
||||
*
|
||||
* @param string $code
|
||||
* @return \Illuminate\Support\Collection|null
|
||||
*/
|
||||
public function getPosInventoryByWarehouseCode(string $code);
|
||||
|
||||
/**
|
||||
* 處理批量入庫邏輯 (含批號產生與現有批號累加)。
|
||||
*
|
||||
* @param \App\Modules\Inventory\Models\Warehouse $warehouse
|
||||
* @param array $items 入庫品項清單
|
||||
* @param array $meta 資料包含 inboundDate, reason, notes
|
||||
* @return void
|
||||
*/
|
||||
public function processIncomingInventory(\App\Modules\Inventory\Models\Warehouse $warehouse, array $items, array $meta): void;
|
||||
|
||||
/**
|
||||
* 處理單一庫存項目的調整。
|
||||
*
|
||||
* @param \App\Modules\Inventory\Models\Inventory $inventory
|
||||
* @param array $data 包含 quantity, operation, type, reason, unit_cost 等
|
||||
* @return void
|
||||
*/
|
||||
public function adjustInventory(\App\Modules\Inventory\Models\Inventory $inventory, array $data): void;
|
||||
}
|
||||
81
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
81
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Contracts;
|
||||
|
||||
/**
|
||||
* 產品服務介面 — 供跨模組使用(如 Integration 模組)。
|
||||
*/
|
||||
interface ProductServiceInterface
|
||||
{
|
||||
/**
|
||||
* 透過外部 POS ID 進行產品新增或更新(Upsert)。
|
||||
*
|
||||
* @param array $data
|
||||
* @return object
|
||||
*/
|
||||
public function upsertFromPos(array $data);
|
||||
|
||||
/**
|
||||
* 透過外部 POS ID 查找產品。
|
||||
*
|
||||
* @param string $externalPosId
|
||||
* @return object|null
|
||||
*/
|
||||
public function findByExternalPosId(string $externalPosId);
|
||||
|
||||
/**
|
||||
* 透過多個外部 POS ID 查找產品。
|
||||
*
|
||||
* @param array $externalPosIds
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByExternalPosIds(array $externalPosIds);
|
||||
|
||||
/**
|
||||
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||
*
|
||||
* @param array $codes
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByCodes(array $codes);
|
||||
|
||||
/**
|
||||
* 建立新商品。
|
||||
*
|
||||
* @param array $data
|
||||
* @return \App\Modules\Inventory\Models\Product
|
||||
*/
|
||||
public function createProduct(array $data);
|
||||
|
||||
/**
|
||||
* 更新現有商品。
|
||||
*
|
||||
* @param \App\Modules\Inventory\Models\Product $product
|
||||
* @param array $data
|
||||
* @return \App\Modules\Inventory\Models\Product
|
||||
*/
|
||||
public function updateProduct(\App\Modules\Inventory\Models\Product $product, array $data);
|
||||
|
||||
/**
|
||||
* 生成隨機 8 碼代號 (大寫英文+數字)。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function generateRandomCode();
|
||||
|
||||
/**
|
||||
* 生成隨機 13 碼條碼 (純數字)。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function generateRandomBarcode();
|
||||
|
||||
/**
|
||||
* 根據條碼或代號查找商品。
|
||||
*
|
||||
* @param string|null $barcode
|
||||
* @param string|null $code
|
||||
* @return \App\Modules\Inventory\Models\Product|null
|
||||
*/
|
||||
public function findByBarcodeOrCode(?string $barcode, ?string $code);
|
||||
}
|
||||
246
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
246
app/Modules/Inventory/Controllers/AdjustDocController.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$docs = $query->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->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::select('id', 'name')->get()->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 ($countDoc->status !== 'completed') {
|
||||
$errorMsg = $countDoc->status === 'no_adjust'
|
||||
? '此盤點單無庫存差異,無需建立盤調單'
|
||||
: '只有已完成盤點的單據可以建立盤調單';
|
||||
return redirect()->back()->with('error', $errorMsg);
|
||||
}
|
||||
|
||||
// 檢查是否已存在對應的盤調單 (避免重複建立)
|
||||
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 update(Request $request, InventoryAdjustDoc $doc)
|
||||
{
|
||||
$action = $request->input('action', 'update');
|
||||
|
||||
if ($action === 'post') {
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只有草稿狀態的單據可以過帳');
|
||||
}
|
||||
$this->adjustService->post($doc, auth()->id());
|
||||
return redirect()->back()->with('success', '單據已過帳');
|
||||
}
|
||||
|
||||
if ($action === 'void') {
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只有草稿狀態的單據可以作廢');
|
||||
}
|
||||
$this->adjustService->void($doc, auth()->id());
|
||||
return redirect()->back()->with('success', '單據已作廢');
|
||||
}
|
||||
|
||||
// 一般更新 (更新品項與基本資訊)
|
||||
if ($doc->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只有草稿狀態的單據可以修改');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'reason' => 'required|string',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required',
|
||||
'items.*.adjust_qty' => 'required|numeric',
|
||||
]);
|
||||
|
||||
$doc->update([
|
||||
'reason' => $request->reason,
|
||||
'remarks' => $request->remarks,
|
||||
]);
|
||||
|
||||
$this->adjustService->updateItems($doc, $request->items);
|
||||
|
||||
return redirect()->back()->with('success', '單據已更新');
|
||||
}
|
||||
|
||||
public function show(InventoryAdjustDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
|
||||
|
||||
// Pre-fetch relevant Inventory information (mainly for expiry date)
|
||||
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
|
||||
->where('warehouse_id', $doc->warehouse_id)
|
||||
->whereIn('product_id', $doc->items->pluck('product_id'))
|
||||
->whereIn('batch_number', $doc->items->pluck('batch_number'))
|
||||
->get()
|
||||
->mapWithKeys(function ($inv) {
|
||||
return [$inv->product_id . '-' . $inv->batch_number => $inv];
|
||||
});
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_id' => (string) $doc->warehouse_id,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'status' => $doc->status,
|
||||
'reason' => $doc->reason,
|
||||
'remarks' => $doc->remarks,
|
||||
'created_at' => $doc->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
|
||||
'count_doc_no' => $doc->countDoc?->doc_no,
|
||||
'items' => $doc->items->map(function ($item) use ($inventoryMap) {
|
||||
$inv = $inventoryMap->get($item->product_id . '-' . $item->batch_number);
|
||||
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,
|
||||
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
'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 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', '盤調單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryController extends Controller
|
||||
239
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
239
app/Modules/Inventory/Controllers/CountDocController.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?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}%");
|
||||
});
|
||||
}
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$countQuery = function ($query) {
|
||||
$query->whereNotNull('counted_qty');
|
||||
};
|
||||
|
||||
$docs = $query->withCount(['items', 'items as counted_items_count' => $countQuery])
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(function ($doc) {
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'status' => $doc->status,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-',
|
||||
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-',
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'remarks' => $doc->remarks,
|
||||
'total_items' => $doc->items_count,
|
||||
'counted_items' => $doc->counted_items_count,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Inventory/Count/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get()->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, false);
|
||||
|
||||
return redirect()->route('inventory.count.show', [$doc->id])
|
||||
->with('success', '已建立盤點單並完成庫存快照');
|
||||
}
|
||||
|
||||
public function show(InventoryCountDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
// 預先抓取相關的 Inventory 資訊 (主要為了取得效期)
|
||||
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
|
||||
->where('warehouse_id', $doc->warehouse_id)
|
||||
->whereIn('product_id', $doc->items->pluck('product_id'))
|
||||
->whereIn('batch_number', $doc->items->pluck('batch_number'))
|
||||
->get()
|
||||
->mapWithKeys(function ($inv) {
|
||||
return [$inv->product_id . '-' . $inv->batch_number => $inv];
|
||||
});
|
||||
|
||||
$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) use ($inventoryMap) {
|
||||
$key = $item->product_id . '-' . $item->batch_number;
|
||||
$inv = $inventoryMap->get($key);
|
||||
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, // 新增效期
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'system_qty' => (float) $item->system_qty,
|
||||
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
|
||||
'diff_qty' => (float) $item->diff_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Count/Show', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(InventoryCountDoc $doc)
|
||||
{
|
||||
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
|
||||
|
||||
$docData = [
|
||||
'id' => (string) $doc->id,
|
||||
'doc_no' => $doc->doc_no,
|
||||
'warehouse_name' => $doc->warehouse->name,
|
||||
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d') : date('Y-m-d'), // Use date only
|
||||
'created_at' => $doc->created_at->format('Y-m-d'),
|
||||
'print_date' => date('Y-m-d'),
|
||||
'created_by' => $doc->createdBy?->name,
|
||||
'items' => $doc->items->map(function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'product_name' => $item->product->name,
|
||||
'product_code' => $item->product->code,
|
||||
'specification' => $item->product->specification,
|
||||
'unit' => $item->product->baseUnit?->name,
|
||||
'quantity' => (float) ($item->counted_qty ?? $item->system_qty), // Default to system qty if counted is null, or just counted? User wants "Count Sheet" -> maybe blank if not counted?
|
||||
// Actually, if it's "Completed", we show counted. If it's "Pending", we usually show blank or system.
|
||||
// The 'Show' page logic suggests we show counted_qty.
|
||||
'counted_qty' => $item->counted_qty,
|
||||
'notes' => $item->notes,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return Inertia::render('Inventory/Count/Print', [
|
||||
'doc' => $docData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '此盤點單已完成,無法修改');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'array',
|
||||
'items.*.id' => 'required|exists:inventory_count_items,id',
|
||||
'items.*.counted_qty' => 'nullable|numeric|min:0',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if (isset($validated['items'])) {
|
||||
$this->countService->updateCount($doc, $validated['items']);
|
||||
}
|
||||
|
||||
// 重新讀取以獲取最新狀態
|
||||
$doc->refresh();
|
||||
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點完成,單據已自動存檔並完成。');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '盤點資料已暫存');
|
||||
}
|
||||
|
||||
public function reopen(InventoryCountDoc $doc)
|
||||
{
|
||||
// 權限檢查 (通常僅允許有權限者執行,例如 inventory.adjust)
|
||||
// 注意:前端已經用 <Can> 保護按鈕,後端這裡最好也加上檢查
|
||||
if (!auth()->user()->can('inventory.adjust')) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!in_array($doc->status, ['completed', 'no_adjust'])) {
|
||||
return redirect()->back()->with('error', '僅能針對已完成或無需盤調的盤點單重新開啟盤點');
|
||||
}
|
||||
|
||||
// 執行取消核准邏輯
|
||||
$doc->update([
|
||||
'status' => 'counting', // 回復為盤點中
|
||||
'completed_at' => null, // 清除完成時間
|
||||
'completed_by' => null, // 清除完成者
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '已重新開啟盤點,單據回復為盤點中狀態');
|
||||
}
|
||||
|
||||
public function destroy(InventoryCountDoc $doc)
|
||||
{
|
||||
if ($doc->status === 'completed') {
|
||||
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
|
||||
}
|
||||
|
||||
// Activity Log handled by Model Trait
|
||||
|
||||
$doc->items()->delete();
|
||||
$doc->delete();
|
||||
|
||||
return redirect()->route('inventory.count.index')
|
||||
->with('success', '盤點單已刪除');
|
||||
}
|
||||
}
|
||||
488
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
488
app/Modules/Inventory/Controllers/GoodsReceiptController.php
Normal file
@@ -0,0 +1,488 @@
|
||||
<?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 Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Inventory\Services\DuplicateCheckService;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
protected $goodsReceiptService;
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
protected $duplicateCheckService;
|
||||
|
||||
public function __construct(
|
||||
GoodsReceiptService $goodsReceiptService,
|
||||
InventoryService $inventoryService,
|
||||
ProcurementServiceInterface $procurementService,
|
||||
DuplicateCheckService $duplicateCheckService
|
||||
) {
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
$this->duplicateCheckService = $duplicateCheckService;
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
// 每頁筆數
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$receipts = $query->orderBy('created_at', 'desc')
|
||||
->paginate($perPage)
|
||||
->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(Request $request, $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,
|
||||
'navigation' => [
|
||||
'from' => $request->query('from'),
|
||||
'from_id' => $request->query('from_id'),
|
||||
'from_label' => $request->query('from_label'),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
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,
|
||||
'supplierId' => $po->vendor_id, // Alias for frontend
|
||||
'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));
|
||||
|
||||
// 獲取單位名稱
|
||||
$baseUnitName = $product?->baseUnit?->name ?? '個';
|
||||
$largeUnitName = $product?->largeUnit?->name ?? '';
|
||||
|
||||
// 判斷當前採購使用的單位 (這需要從 PurchaseOrderItem 獲取 unit_id 並與產品的 large_unit_id 比較)
|
||||
$selectedUnit = 'base';
|
||||
if ($item->unit_id && $product && $item->unit_id == $product->large_unit_id) {
|
||||
$selectedUnit = 'large';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'productId' => $item->product_id, // Alias for frontend
|
||||
'product_id' => $item->product_id,
|
||||
'productName' => $product?->name ?? '', // Alias for frontend
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $product?->baseUnit?->name ?? '個', // 預設顯示文字
|
||||
'selectedUnit' => $selectedUnit,
|
||||
'base_unit_id' => $product?->base_unit_id,
|
||||
'base_unit_name' => $baseUnitName,
|
||||
'large_unit_id' => $product?->large_unit_id,
|
||||
'large_unit_name' => $largeUnitName,
|
||||
'conversion_rate' => $product?->conversion_rate ?? 1,
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unitPrice' => $item->unit_price, // Alias for frontend
|
||||
'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($this->getItemValidationRules());
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->store($request->all());
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 編輯進貨單(僅草稿/退回狀態)
|
||||
*/
|
||||
public function edit(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, 'rejected'])) {
|
||||
return redirect()->route('goods-receipts.show', $goodsReceipt->id)
|
||||
->with('error', '只有草稿或被退回的進貨單可以編輯。');
|
||||
}
|
||||
|
||||
// 載入品項與產品資訊
|
||||
$goodsReceipt->load('items');
|
||||
|
||||
// 取得品項關聯的商品資訊
|
||||
$productIds = $goodsReceipt->items->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// 如果是標準採購,取得對應採購單的品項,以帶出預定數量與已收數量
|
||||
$poItems = collect();
|
||||
$po = null;
|
||||
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id) {
|
||||
$po = clone $this->procurementService->getPurchaseOrdersByIds([$goodsReceipt->purchase_order_id], ['items', 'vendor'])->first();
|
||||
if ($po) {
|
||||
$poItems = $po->items->keyBy('id');
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化品項資料
|
||||
$formattedItems = $goodsReceipt->items->map(function ($item) use ($products, $poItems) {
|
||||
$product = $products->get($item->product_id);
|
||||
$poItem = $poItems->get($item->purchase_order_item_id);
|
||||
|
||||
// 判斷單位
|
||||
$selectedUnit = 'base';
|
||||
if ($poItem && $product && $poItem->unit_id && $poItem->unit_id == $product->large_unit_id) {
|
||||
$selectedUnit = 'large';
|
||||
}
|
||||
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'purchase_order_item_id' => $item->purchase_order_item_id,
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $poItem && $selectedUnit === 'large' ? ($product?->largeUnit?->name ?? '') : ($product?->baseUnit?->name ?? '個'),
|
||||
'selectedUnit' => $selectedUnit,
|
||||
'base_unit_id' => $product?->base_unit_id,
|
||||
'base_unit_name' => $product?->baseUnit?->name ?? '個',
|
||||
'large_unit_id' => $product?->large_unit_id,
|
||||
'large_unit_name' => $product?->largeUnit?->name ?? '',
|
||||
'conversion_rate' => $product?->conversion_rate ?? 1,
|
||||
'quantity_ordered' => $poItem ? $poItem->quantity : null,
|
||||
'quantity_received_so_far' => $poItem ? ($poItem->received_quantity ?? 0) : null,
|
||||
'quantity_received' => (float) $item->quantity_received,
|
||||
'unit_price' => (float) $item->unit_price,
|
||||
'subtotal' => (float) $item->total_amount,
|
||||
'batch_number' => $item->batch_number ?? '',
|
||||
'batchMode' => 'existing',
|
||||
'originCountry' => 'TW',
|
||||
'expiry_date' => $item->expiry_date ?? '',
|
||||
];
|
||||
})->values();
|
||||
|
||||
// 同 create() 一樣傳入所需的 props
|
||||
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
|
||||
$productIdsForPOs = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
|
||||
$productsForPOs = $this->inventoryService->getProductsByIds($productIdsForPOs)->keyBy('id');
|
||||
|
||||
$formattedPOs = $pendingPOs->map(function ($po) use ($productsForPOs) {
|
||||
return [
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'supplierId' => $po->vendor_id,
|
||||
'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 ($productsForPOs) {
|
||||
$product = $productsForPOs->get($item->product_id);
|
||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
||||
|
||||
$selectedUnit = 'base';
|
||||
if ($item->unit_id && $product && $item->unit_id == $product->large_unit_id) {
|
||||
$selectedUnit = 'large';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'productId' => $item->product_id,
|
||||
'product_id' => $item->product_id,
|
||||
'productName' => $product?->name ?? '',
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $item->unit_id && $product && $item->unit_id == $product->large_unit_id ? ($product?->largeUnit?->name ?? '') : ($product?->baseUnit?->name ?? '個'),
|
||||
'selectedUnit' => $selectedUnit,
|
||||
'base_unit_id' => $product?->base_unit_id,
|
||||
'base_unit_name' => $product?->baseUnit?->name ?? '個',
|
||||
'large_unit_id' => $product?->large_unit_id,
|
||||
'large_unit_name' => $product?->largeUnit?->name ?? '',
|
||||
'conversion_rate' => $product?->conversion_rate ?? 1,
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unitPrice' => $item->unit_price,
|
||||
'unit_price' => $item->unit_price,
|
||||
];
|
||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||
];
|
||||
})->filter(fn($po) => $po['items']->count() > 0)->values();
|
||||
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
// Manual Hydration for Vendor
|
||||
$vendor = null;
|
||||
if ($goodsReceipt->vendor_id) {
|
||||
$vendor = $this->procurementService->getVendorsByIds([$goodsReceipt->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 格式化 Purchase Order 給前端顯示
|
||||
$formattedPO = null;
|
||||
if ($po) {
|
||||
$formattedPO = [
|
||||
'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->toArray(), // simplified since we just need items.length for display
|
||||
];
|
||||
}
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Create', [
|
||||
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||
'pendingPurchaseOrders' => $formattedPOs,
|
||||
'vendors' => $vendors,
|
||||
'receipt' => [
|
||||
'id' => $goodsReceipt->id,
|
||||
'code' => $goodsReceipt->code,
|
||||
'type' => $goodsReceipt->type,
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'vendor_id' => $goodsReceipt->vendor_id,
|
||||
'vendor' => $vendor,
|
||||
'purchase_order_id' => $goodsReceipt->purchase_order_id,
|
||||
'purchase_order' => $formattedPO,
|
||||
'received_date' => \Carbon\Carbon::parse($goodsReceipt->received_date)->format('Y-m-d'),
|
||||
'remarks' => $goodsReceipt->remarks,
|
||||
'items' => $formattedItems,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新進貨單
|
||||
*/
|
||||
public function update(Request $request, GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
$validated = $request->validate($this->getItemValidationRules());
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->update($goodsReceipt, $request->all());
|
||||
return redirect()->route('goods-receipts.show', $goodsReceipt->id)->with('success', '進貨單已更新');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得品項驗證規則
|
||||
*/
|
||||
private function getItemValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'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' => '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' => 'nullable|numeric|min:0',
|
||||
'items.*.subtotal' => 'nullable|numeric|min:0',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.expiry_date' => 'nullable|date',
|
||||
'force' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 預檢重複進貨 API
|
||||
*/
|
||||
public function checkDuplicate(Request $request)
|
||||
{
|
||||
$result = $this->duplicateCheckService->checkDuplicateReceipt($request->all());
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function submit(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
if (!auth()->user()->can('goods_receipts.edit')) {
|
||||
return back()->with('error', '您沒有權限確認點收');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->submit($goodsReceipt);
|
||||
return back()->with('success', '進貨單已點收完成,庫存已增加並拋轉應付帳款');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 支援 query='*' 回傳所有商品(用於 SearchableSelect 下拉選單)
|
||||
public function searchProducts(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
if (!$search) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
// 萬用字元:回傳所有商品
|
||||
if ($search === '*') {
|
||||
$products = $this->inventoryService->getProductsByName('');
|
||||
} else {
|
||||
$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 ?? '個',
|
||||
'price' => $product->purchase_price ?? 0,
|
||||
];
|
||||
});
|
||||
|
||||
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', '進貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\TurnoverService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class InventoryAnalysisController extends Controller
|
||||
{
|
||||
protected $turnoverService;
|
||||
|
||||
public function __construct(TurnoverService $turnoverService)
|
||||
{
|
||||
$this->turnoverService = $turnoverService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'warehouse_id', 'category_id', 'search', 'per_page', 'sort_by', 'sort_order', 'status'
|
||||
]);
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$analysisData = $this->turnoverService->getAnalysisData($filters, $perPage);
|
||||
$kpis = $this->turnoverService->getKPIs($filters);
|
||||
|
||||
return Inertia::render('Inventory/Analysis/Index', [
|
||||
'analysisData' => $analysisData,
|
||||
'kpis' => $kpis,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
'categories' => Category::select('id', 'name')->get(),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
}
|
||||
528
app/Modules/Inventory/Controllers/InventoryController.php
Normal file
528
app/Modules/Inventory/Controllers/InventoryController.php
Normal file
@@ -0,0 +1,528 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
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\Inventory\Imports\InventoryImport;
|
||||
use App\Modules\Inventory\Exports\InventoryTemplateExport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
protected $coreService;
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(
|
||||
CoreServiceInterface $coreService,
|
||||
\App\Modules\Inventory\Contracts\InventoryServiceInterface $inventoryService
|
||||
) {
|
||||
$this->coreService = $coreService;
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
public function index(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
// ... (existing code for index) ...
|
||||
$warehouse->load([
|
||||
'inventories.product.category',
|
||||
'inventories.product.baseUnit',
|
||||
'inventories.lastIncomingTransaction',
|
||||
'inventories.lastOutgoingTransaction'
|
||||
]);
|
||||
$allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
|
||||
|
||||
// 1. 準備 availableProducts
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->category?->name ?? '其他',
|
||||
];
|
||||
});
|
||||
|
||||
// 2. 從新表格讀取安全庫存設定 (商品-倉庫層級)
|
||||
$safetyStockMap = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->pluck('safety_stock', 'product_id')
|
||||
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
||||
|
||||
$query = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction']);
|
||||
|
||||
// 加入搜尋過濾
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('batch_number', 'like', "%{$search}%")
|
||||
->orWhere(\Illuminate\Support\Facades\DB::raw("CONCAT('BATCH-', inventories.id)"), 'like', "%{$search}%")
|
||||
->orWhereHas('product', function ($pq) use ($search) {
|
||||
$pq->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 加入類型過濾
|
||||
if ($request->filled('type') && $request->input('type') !== 'all') {
|
||||
$type = $request->input('type');
|
||||
$query->whereHas('product.category', function ($cq) use ($type) {
|
||||
$cq->where('name', $type);
|
||||
});
|
||||
}
|
||||
|
||||
$items = $query->get();
|
||||
|
||||
// 判斷是否為販賣機並調整分組
|
||||
$isVending = $warehouse->type === 'vending';
|
||||
|
||||
$inventories = $items->groupBy(function ($item) use ($isVending) {
|
||||
return $isVending
|
||||
? $item->product_id . '-' . ($item->location ?? 'NO-SLOT')
|
||||
: $item->product_id;
|
||||
})->map(function ($batchItems) use ($safetyStockMap, $isVending) {
|
||||
$firstItem = $batchItems->first();
|
||||
$product = $firstItem->product;
|
||||
$totalQuantity = $batchItems->sum('quantity');
|
||||
$totalValue = $batchItems->sum('total_value'); // 計算總價值
|
||||
|
||||
// 從獨立表格讀取安全庫存
|
||||
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
|
||||
|
||||
// 計算狀態
|
||||
$status = '正常';
|
||||
if (!is_null($safetyStock)) {
|
||||
if ($totalQuantity < $safetyStock) {
|
||||
$status = '低於';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'productId' => (string) $firstItem->product_id,
|
||||
'productName' => $product?->name ?? '未知商品',
|
||||
'productCode' => $product?->code ?? 'N/A',
|
||||
'baseUnit' => $product?->baseUnit?->name ?? '個',
|
||||
'totalQuantity' => (float) $totalQuantity,
|
||||
'totalValue' => (float) $totalValue,
|
||||
'safetyStock' => $safetyStock,
|
||||
'status' => $status,
|
||||
'batches' => $batchItems->map(function ($inv) {
|
||||
return [
|
||||
'id' => (string) $inv->id,
|
||||
'warehouseId' => (string) $inv->warehouse_id,
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product?->name ?? '未知商品',
|
||||
'productCode' => $inv->product?->code ?? 'N/A',
|
||||
'unit' => $inv->product?->baseUnit?->name ?? '個',
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'unit_cost' => (float) $inv->unit_cost,
|
||||
'total_value' => (float) $inv->total_value,
|
||||
'safetyStock' => null, // 批號層級不再有安全庫存
|
||||
'status' => '正常',
|
||||
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
||||
'location' => $inv->location,
|
||||
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? substr($inv->lastIncomingTransaction->actual_time, 0, 10) : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? substr($inv->lastOutgoingTransaction->actual_time, 0, 10) : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
// 4. 準備 safetyStockSettings (從新表格讀取)
|
||||
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->with(['product.category'])
|
||||
->get()
|
||||
->map(function ($setting) {
|
||||
return [
|
||||
'id' => (string) $setting->id,
|
||||
'warehouseId' => (string) $setting->warehouse_id,
|
||||
'productId' => (string) $setting->product_id,
|
||||
'productName' => $setting->product?->name ?? '未知商品',
|
||||
'productType' => $setting->product?->category?->name ?? '其他',
|
||||
'safetyStock' => (float) $setting->safety_stock,
|
||||
'createdAt' => $setting->created_at->toIso8601String(),
|
||||
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/Inventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventories' => $inventories,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
'availableProducts' => $availableProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Warehouse $warehouse)
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'costPrice' => (float) $product->cost_price,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/AddInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$validated = $request->validate([
|
||||
'inboundDate' => 'required|date',
|
||||
'reason' => 'required|string',
|
||||
'notes' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
|
||||
'items.*.batchMode' => 'required|in:existing,new,none',
|
||||
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
|
||||
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
|
||||
'items.*.expiryDate' => 'nullable|date',
|
||||
'items.*.location' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated, $warehouse) {
|
||||
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
|
||||
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
|
||||
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
|
||||
$dt->setTimeFrom(now());
|
||||
} else {
|
||||
$dt->setSecond(now()->second);
|
||||
}
|
||||
$inboundDateTime = $dt->toDateTimeString();
|
||||
|
||||
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
|
||||
'inboundDate' => $inboundDateTime,
|
||||
'reason' => $validated['reason'],
|
||||
'notes' => $validated['notes'] ?? '',
|
||||
]);
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||
->with('success', '庫存記錄已儲存成功');
|
||||
});
|
||||
}
|
||||
|
||||
// ... (getBatches unchanged) ...
|
||||
public function getBatches(Warehouse $warehouse, $productId, Request $request)
|
||||
{
|
||||
$originCountry = $request->query('originCountry', 'TW');
|
||||
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
|
||||
|
||||
$batches = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->get()
|
||||
->map(function ($inventory) {
|
||||
return [
|
||||
'inventoryId' => (string) $inventory->id,
|
||||
'batchNumber' => $inventory->batch_number,
|
||||
'originCountry' => $inventory->origin_country,
|
||||
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'unitCost' => (float) $inventory->unit_cost,
|
||||
'location' => $inventory->location,
|
||||
];
|
||||
});
|
||||
|
||||
// 計算下一個流水號
|
||||
$product = Product::find($productId);
|
||||
$nextSequence = '01';
|
||||
if ($product) {
|
||||
$batchNumber = Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
$originCountry,
|
||||
$arrivalDate
|
||||
);
|
||||
$nextSequence = substr($batchNumber, -2);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'batches' => $batches,
|
||||
'nextSequence' => $nextSequence
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function edit(Request $request, Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
if (str_starts_with($inventoryId, 'mock-inv-')) {
|
||||
return redirect()->back()->with('error', '無法編輯範例資料');
|
||||
}
|
||||
|
||||
// 移除 'transactions.user' 預載入
|
||||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}])->findOrFail($inventoryId);
|
||||
|
||||
// 手動 Hydrate 使用者資料
|
||||
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
// 轉換為前端需要的格式
|
||||
$inventoryData = [
|
||||
'id' => (string) $inventory->id,
|
||||
'warehouseId' => (string) $inventory->warehouse_id,
|
||||
'productId' => (string) $inventory->product_id,
|
||||
'productName' => $inventory->product?->name ?? '未知商品',
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'unit_cost' => (float) $inventory->unit_cost,
|
||||
'total_value' => (float) $inventory->total_value,
|
||||
'batchNumber' => $inventory->batch_number ?? '-',
|
||||
'expiryDate' => $inventory->expiry_date ?? null,
|
||||
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
|
||||
'lastOutboundDate' => null,
|
||||
];
|
||||
|
||||
// 整理異動紀錄
|
||||
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
|
||||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/EditInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => $inventoryData,
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$inventory = Inventory::find($inventoryId);
|
||||
|
||||
// 如果找不到 (可能是舊路由傳 product ID)
|
||||
if (!$inventory) {
|
||||
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
|
||||
}
|
||||
|
||||
if (!$inventory) {
|
||||
return redirect()->back()->with('error', '找不到庫存紀錄');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'quantity' => 'required|numeric|min:0',
|
||||
// 以下欄位改為 nullable,支援新表單
|
||||
'type' => 'nullable|string',
|
||||
'operation' => 'nullable|in:add,subtract,set',
|
||||
'reason' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'unit_cost' => 'nullable|numeric|min:0', // 新增成本
|
||||
// ...
|
||||
'batchNumber' => 'nullable|string',
|
||||
'expiryDate' => 'nullable|date',
|
||||
'lastInboundDate' => 'nullable|date',
|
||||
'lastOutboundDate' => 'nullable|date',
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated, $inventory) {
|
||||
$this->inventoryService->adjustInventory($inventory, $validated);
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
|
||||
->with('success', '庫存資料已更新');
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$inventory = Inventory::findOrFail($inventoryId);
|
||||
|
||||
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
||||
if ($inventory->quantity > 0) {
|
||||
return redirect()->back()->with('error', '庫存數量大於 0,無法刪除。請先進行出庫或調整。');
|
||||
}
|
||||
|
||||
// 歸零異動 (因為已經限制為 0 才能刪,這段邏輯可以簡化,但為了保險起見,若有微小殘值仍可記錄歸零)
|
||||
if (abs($inventory->quantity) > 0.0001) {
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動編輯',
|
||||
'quantity' => -$inventory->quantity,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $inventory->quantity,
|
||||
'balance_after' => 0,
|
||||
'reason' => '刪除庫存品項',
|
||||
'actual_time' => now(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory->delete();
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||
->with('success', '庫存品項已刪除');
|
||||
}
|
||||
|
||||
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
{
|
||||
// ... (前端 history 頁面可能也需要 unit_cost,這裡可補上) ...
|
||||
$inventoryId = $request->query('inventoryId');
|
||||
$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 ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
|
||||
'slot' => $tx->inventory?->location, // 加入貨道資訊
|
||||
];
|
||||
});
|
||||
|
||||
// 重新計算目前的總量(用於 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) {
|
||||
// 單一批號查詢
|
||||
// 移除 'transactions.user'
|
||||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}])->findOrFail($inventoryId);
|
||||
|
||||
// 手動 Hydrate 使用者資料
|
||||
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$transactions = $inventory->transactions->map(function ($tx) use ($users, $inventory) {
|
||||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
'slot' => $inventory->location, // 加入貨道資訊
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => (string) $inventory->id,
|
||||
'productName' => $inventory->product?->name ?? '未知商品',
|
||||
'productCode' => $inventory->product?->code ?? 'N/A',
|
||||
'batchNumber' => $inventory->batch_number ?? '-',
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'unit_cost' => (float) $inventory->unit_cost,
|
||||
'total_value' => (float) $inventory->total_value,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', '未提供查詢參數');
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯入入庫
|
||||
*/
|
||||
public function import(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|mimes:xlsx,xls,csv',
|
||||
'inboundDate' => 'required|date',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
Excel::import(
|
||||
new InventoryImport($warehouse, $request->inboundDate, $request->notes),
|
||||
$request->file('file')
|
||||
);
|
||||
|
||||
return back()->with('success', '庫存資料匯入成功');
|
||||
} catch (\Exception $e) {
|
||||
return back()->withErrors(['file' => '匯入過程中發生錯誤: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載匯入範本 (.xlsx)
|
||||
*/
|
||||
public function template()
|
||||
{
|
||||
return Excel::download(new InventoryTemplateExport, '庫存匯入範本.xlsx');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\InventoryReportService;
|
||||
use App\Modules\Inventory\Exports\InventoryReportExport;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
|
||||
class InventoryReportController extends Controller
|
||||
{
|
||||
protected $reportService;
|
||||
|
||||
public function __construct(InventoryReportService $reportService)
|
||||
{
|
||||
$this->reportService = $reportService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id', 'category_id', 'search', 'per_page',
|
||||
'sort_by', 'sort_order'
|
||||
]);
|
||||
|
||||
if (!isset($filters['date_from'])) {
|
||||
$filters['date_from'] = date('Y-m-d');
|
||||
}
|
||||
if (!isset($filters['date_to'])) {
|
||||
$filters['date_to'] = date('Y-m-d');
|
||||
}
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = (int) $request->input('per_page', $defaultPerPage);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
$reportData = $this->reportService->getReportData($filters, $perPage);
|
||||
$summary = $this->reportService->getSummary($filters);
|
||||
|
||||
return Inertia::render('Inventory/Report/Index', [
|
||||
'reportData' => $reportData,
|
||||
'summary' => $summary,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
'categories' => Category::select('id', 'name')->get(),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'period', 'date_from', 'date_to', 'warehouse_id', 'category_id', 'search'
|
||||
]);
|
||||
|
||||
return Excel::download(new InventoryReportExport($this->reportService, $filters), 'inventory_report_' . date('YmdHis') . '.xlsx');
|
||||
}
|
||||
|
||||
public function show(Request $request, $productId)
|
||||
{
|
||||
// 明細頁面自身使用的篩選條件
|
||||
$filters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id'
|
||||
]);
|
||||
|
||||
// 報表頁面的完整篩選狀態(用於返回時恢復)
|
||||
$reportFilters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id',
|
||||
'category_id', 'search', 'per_page'
|
||||
]);
|
||||
// 將傳入的 report_page 轉回 page 以便 Link 元件正確生成回報表頁的連結
|
||||
if ($request->has('report_page')) {
|
||||
$reportFilters['page'] = $request->input('report_page');
|
||||
}
|
||||
|
||||
// 取得商品資訊 (用於顯示標題,含基本單位)
|
||||
$product = \App\Modules\Inventory\Models\Product::with('baseUnit')->findOrFail($productId);
|
||||
|
||||
$transactions = $this->reportService->getProductDetails($productId, $filters, 20);
|
||||
|
||||
return Inertia::render('Inventory/Report/Show', [
|
||||
'product' => [
|
||||
'id' => $product->id,
|
||||
'code' => $product->code,
|
||||
'name' => $product->name,
|
||||
'unit_name' => $product->baseUnit?->name ?? '-',
|
||||
],
|
||||
'transactions' => $transactions,
|
||||
'filters' => $filters,
|
||||
'reportFilters' => $reportFilters,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
313
app/Modules/Inventory/Controllers/ProductController.php
Normal file
313
app/Modules/Inventory/Controllers/ProductController.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Modules\Inventory\Exports\ProductTemplateExport;
|
||||
use App\Modules\Inventory\Imports\ProductImport;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
protected $productService;
|
||||
|
||||
public function __construct(\App\Modules\Inventory\Contracts\ProductServiceInterface $productService)
|
||||
{
|
||||
$this->productService = $productService;
|
||||
}
|
||||
/**
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('brand', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category_id') && $request->category_id !== 'all') {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
|
||||
$perPage = $request->input('per_page', $defaultPerPage);
|
||||
|
||||
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
|
||||
$perPage = $defaultPerPage;
|
||||
}
|
||||
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
|
||||
// 定義允許的排序欄位以防止 SQL 注入
|
||||
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
|
||||
if (!in_array($sortField, $allowedSorts)) {
|
||||
$sortField = 'id';
|
||||
}
|
||||
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
||||
$sortDirection = 'desc';
|
||||
}
|
||||
|
||||
// 如果需要,分別處理關聯排序(分類名稱),或簡單的 join
|
||||
if ($sortField === 'category_id') {
|
||||
// 加入分類以便按名稱排序?還是僅按 ID?
|
||||
// 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。
|
||||
// 先假設標準欄位排序。
|
||||
$query->orderBy('category_id', $sortDirection);
|
||||
} else {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
}
|
||||
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
$products->getCollection()->transform(function ($product) {
|
||||
return (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'category' => $product->category ? (object) [
|
||||
'id' => $product->category->id,
|
||||
'name' => $product->category->name,
|
||||
] : null,
|
||||
'brand' => $product->brand,
|
||||
'specification' => $product->specification,
|
||||
'baseUnitId' => $product->base_unit_id,
|
||||
'baseUnit' => $product->baseUnit ? (object) [
|
||||
'id' => $product->baseUnit->id,
|
||||
'name' => $product->baseUnit->name,
|
||||
] : null,
|
||||
'largeUnitId' => $product->large_unit_id,
|
||||
'largeUnit' => $product->largeUnit ? (object) [
|
||||
'id' => $product->largeUnit->id,
|
||||
'name' => $product->largeUnit->name,
|
||||
] : null,
|
||||
'purchaseUnitId' => $product->purchase_unit_id,
|
||||
'purchaseUnit' => $product->purchaseUnit ? (object) [
|
||||
'id' => $product->purchaseUnit->id,
|
||||
'name' => $product->purchaseUnit->name,
|
||||
] : null,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'location' => $product->location,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
'price' => (float) $product->price,
|
||||
'member_price' => (float) $product->member_price,
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
];
|
||||
});
|
||||
|
||||
$categories = Category::select('id', 'name')->where('is_active', true)->get();
|
||||
|
||||
return Inertia::render('Product/Index', [
|
||||
'products' => $products,
|
||||
'categories' => $categories->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示指定的資源。
|
||||
*/
|
||||
public function show(Product $product): Response
|
||||
{
|
||||
return Inertia::render('Product/Show', [
|
||||
'product' => (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'category' => $product->category ? (object) [
|
||||
'id' => $product->category->id,
|
||||
'name' => $product->category->name,
|
||||
] : null,
|
||||
'brand' => $product->brand,
|
||||
'specification' => $product->specification,
|
||||
'baseUnitId' => $product->base_unit_id,
|
||||
'baseUnit' => $product->baseUnit ? (object) [
|
||||
'id' => $product->baseUnit->id,
|
||||
'name' => $product->baseUnit->name,
|
||||
] : null,
|
||||
'largeUnitId' => $product->large_unit_id,
|
||||
'largeUnit' => $product->largeUnit ? (object) [
|
||||
'id' => $product->largeUnit->id,
|
||||
'name' => $product->largeUnit->name,
|
||||
] : null,
|
||||
'purchaseUnitId' => $product->purchase_unit_id,
|
||||
'purchaseUnit' => $product->purchaseUnit ? (object) [
|
||||
'id' => $product->purchaseUnit->id,
|
||||
'name' => $product->purchaseUnit->name,
|
||||
] : null,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'location' => $product->location,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
'price' => (float) $product->price,
|
||||
'member_price' => (float) $product->member_price,
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示建立表單。
|
||||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('Product/Create', [
|
||||
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|unique:products,code',
|
||||
'barcode' => 'nullable|unique:products,barcode',
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
'base_unit_id' => 'required|exists:units,id',
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'nullable|numeric|min:0',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'member_price' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$product = $this->productService->createProduct($validated);
|
||||
|
||||
return redirect()->route('products.index')->with('success', '商品已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯表單。
|
||||
*/
|
||||
public function edit(Product $product): Response
|
||||
{
|
||||
return Inertia::render('Product/Edit', [
|
||||
'product' => (object) [
|
||||
'id' => (string) $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'categoryId' => $product->category_id,
|
||||
'brand' => $product->brand,
|
||||
'specification' => $product->specification,
|
||||
'baseUnitId' => $product->base_unit_id,
|
||||
'largeUnitId' => $product->large_unit_id,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'purchaseUnitId' => $product->purchase_unit_id,
|
||||
'location' => $product->location,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
'price' => (float) $product->price,
|
||||
'member_price' => (float) $product->member_price,
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
],
|
||||
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|unique:products,code,' . $product->id,
|
||||
'barcode' => 'nullable|unique:products,barcode,' . $product->id,
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
'base_unit_id' => 'required|exists:units,id',
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'nullable|numeric|min:0',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'member_price' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$this->productService->updateProduct($product, $validated);
|
||||
|
||||
if ($request->input('from') === 'show') {
|
||||
return redirect()->route('products.show', $product->id)->with('success', '商品已更新');
|
||||
}
|
||||
|
||||
return redirect()->route('products.index')->with('success', '商品已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(Product $product)
|
||||
{
|
||||
$product->delete();
|
||||
|
||||
return redirect()->back()->with('success', '商品已刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載匯入範本
|
||||
*/
|
||||
public function template()
|
||||
{
|
||||
return Excel::download(new ProductTemplateExport, '商品匯入範本.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯入商品
|
||||
*/
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls',
|
||||
]);
|
||||
|
||||
try {
|
||||
Excel::import(new ProductImport, $request->file('file'));
|
||||
return redirect()->back()->with('success', '商品匯入成功');
|
||||
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
|
||||
$failures = $e->failures();
|
||||
$messages = [];
|
||||
foreach ($failures as $failure) {
|
||||
$messages[] = '第 ' . $failure->row() . ' 行: ' . implode(', ', $failure->errors());
|
||||
}
|
||||
return redirect()->back()->withErrors(['file' => implode("\n", $messages)]);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
151
app/Modules/Inventory/Controllers/SafetyStockController.php
Normal file
151
app/Modules/Inventory/Controllers/SafetyStockController.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SafetyStockController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示安全庫存設定頁面
|
||||
*/
|
||||
public function index(Warehouse $warehouse)
|
||||
{
|
||||
$allProducts = Product::select('id', 'name', 'category_id', 'base_unit_id')->with(['category:id,name', 'baseUnit:id,name'])->get();
|
||||
|
||||
// 準備可選商品列表
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->category ? $product->category->name : '其他',
|
||||
'unit' => $product->baseUnit?->name ?? '個',
|
||||
];
|
||||
});
|
||||
|
||||
// 獲取現有庫存 (用於抓取「已在倉庫中」的商品)
|
||||
$inventoryProductIds = Inventory::where('warehouse_id', $warehouse->id)->pluck('product_id')->unique();
|
||||
|
||||
// 準備安全庫存設定列表 (從資料庫讀取)
|
||||
$existingSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->with(['product.category', 'product.baseUnit'])
|
||||
->get();
|
||||
|
||||
$existingProductIds = $existingSettings->pluck('product_id')->toArray();
|
||||
|
||||
// 找出:有庫存但是「還沒設定過安全庫存」的商品
|
||||
$missingProductIds = $inventoryProductIds->diff($existingProductIds);
|
||||
|
||||
$missingProducts = Product::whereIn('id', $missingProductIds)
|
||||
->with(['category', 'baseUnit'])
|
||||
->get();
|
||||
|
||||
// 合併:已設定的 + 有庫存未設定的 (預設值 0)
|
||||
$safetyStockSettings = $existingSettings->map(function ($setting) {
|
||||
return [
|
||||
'id' => (string) $setting->id,
|
||||
'warehouseId' => (string) $setting->warehouse_id,
|
||||
'productId' => (string) $setting->product_id,
|
||||
'productName' => $setting->product->name,
|
||||
'productType' => $setting->product->category ? $setting->product->category->name : '其他',
|
||||
'safetyStock' => (float) $setting->safety_stock,
|
||||
'unit' => $setting->product->baseUnit?->name ?? '個',
|
||||
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||
'isNew' => false, // 標記為舊有設定
|
||||
];
|
||||
})->concat($missingProducts->map(function ($product) use ($warehouse) {
|
||||
return [
|
||||
'id' => 'temp_' . $product->id, // 暫時 ID
|
||||
'warehouseId' => (string) $warehouse->id,
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'productType' => $product->category ? $product->category->name : '其他',
|
||||
'safetyStock' => 0, // 預設 0
|
||||
'unit' => $product->baseUnit?->name ?? '個',
|
||||
'updatedAt' => now()->toIso8601String(),
|
||||
'isNew' => true, // 標記為建議新增
|
||||
];
|
||||
}))->values();
|
||||
|
||||
// 原本的 inventories 映射 (供顯示對比)
|
||||
$inventories = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->select('product_id', DB::raw('SUM(quantity) as total_quantity'))
|
||||
->groupBy('product_id')
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'productId' => (string) $inv->product_id,
|
||||
'quantity' => (float) $inv->total_quantity,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/SafetyStockSettings', [
|
||||
'warehouse' => $warehouse,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
'inventories' => $inventories,
|
||||
'availableProducts' => $availableProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量儲存安全庫存設定
|
||||
*/
|
||||
public function store(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'settings' => 'required|array|min:1',
|
||||
'settings.*.productId' => 'required|exists:products,id',
|
||||
'settings.*.quantity' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['settings'] as $item) {
|
||||
WarehouseProductSafetyStock::updateOrCreate(
|
||||
[
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'product_id' => $item['productId'],
|
||||
],
|
||||
[
|
||||
'safety_stock' => $item['quantity'],
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新單筆安全庫存設定
|
||||
*/
|
||||
public function update(Request $request, Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'safetyStock' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$safetyStock->update([
|
||||
'safety_stock' => $validated['safetyStock'],
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除安全庫存設定
|
||||
*/
|
||||
public function destroy(Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
|
||||
{
|
||||
$safetyStock->delete();
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存設定已移除');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user