Compare commits
16 Commits
89291918fd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd0a047f2 | |||
| e3ceedc579 | |||
| f2e5ef121e | |||
| 7a1fc02dfc | |||
| 5f34855233 | |||
| bee8ecb55b | |||
| 0198b7de90 | |||
| b57a4feeab | |||
| 55a806051d | |||
| 6ca0bafd60 | |||
| adf13410ba | |||
| d52a215916 | |||
| 197df3bec4 | |||
| 2437aa2672 | |||
| a987f4345e | |||
| 2fd5de96b2 |
@@ -20,6 +20,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
|||||||
| 按鈕樣式、表格規範、圖標、分頁、Badge、Toast、表單、UI 統一、頁面佈局、`button-filled-*`、`button-outlined-*`、`lucide-react`、色彩系統 | **客戶端後台 UI 統一規範** | `.agents/skills/ui-consistency/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` |
|
| 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` |
|
| 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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,6 +44,10 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
|||||||
必須讀取:
|
必須讀取:
|
||||||
1. **git-workflows** — 分支命名與 commit 格式
|
1. **git-workflows** — 分支命名與 commit 格式
|
||||||
|
|
||||||
|
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||||
|
必須讀取:
|
||||||
|
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事項
|
## 注意事項
|
||||||
|
|||||||
@@ -781,7 +781,38 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
|||||||
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
||||||
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
||||||
|
|
||||||
## 11.6 日期顯示規範 (Date Display)
|
## 11.6 數字輸入框規範 (Numeric Inputs)
|
||||||
|
|
||||||
|
當需求為輸入**整數**數量(例如:實際產出數量、標準產出量)時,**嚴禁自行開發組合 `Plus` (+) 與 `Minus` (-) 按鈕的複合元件**。
|
||||||
|
|
||||||
|
**必須使用原生 HTML5 數字輸入與屬性**:
|
||||||
|
1. 使用 `<Input type="number" />` 確保預設渲染瀏覽器原生的上下調整小箭頭 (Spinner)。
|
||||||
|
2. 針對整數需求,固定加上 `step="1"` 屬性。
|
||||||
|
3. 視需求加上 `min` 與 `max` 控制上下限。
|
||||||
|
|
||||||
|
這樣既能保持與現有「新增配方」等模組的「標準產出量」欄位行為高度一致,亦能維持畫面的極簡風格。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 正確:依賴原生行為
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
max={outputQuantity}
|
||||||
|
value={actualOutputQuantity}
|
||||||
|
onChange={(e) => setActualOutputQuantity(e.target.value)}
|
||||||
|
className="h-9 w-24 text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// ❌ 錯誤:過度設計、浪費空間與破壞一致性
|
||||||
|
<div className="flex">
|
||||||
|
<Button><Minus /></Button>
|
||||||
|
<Input type="number" />
|
||||||
|
<Button><Plus /></Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11.7 日期顯示規範 (Date Display)
|
||||||
|
|
||||||
前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。
|
前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ description: 將目前的變更提交並推送至指定的遠端分支 (遵守
|
|||||||
3. **目標分支安全檢查**
|
3. **目標分支安全檢查**
|
||||||
- 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
|
- 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
|
||||||
- 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`。
|
- 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`。
|
||||||
|
- **【最嚴格限制】**:`main` 分支的程式碼**只能**, **必須**從 `demo` 分支合併而來。絕對禁止將 `dev` (或 `feature/*`) 直接合併進 `main`。
|
||||||
|
|
||||||
4. **執行推送 (Push)**
|
4. **執行推送 (Push) 與嚴格合併鏈路**
|
||||||
- 通過安全檢查後,執行:`git push origin [目前分支]:[目標分支]`。
|
- **若目標為 `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. **後續同步**
|
5. **後續同步 (針對 Hotfix)**
|
||||||
- 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」評估是否需要同步回 `demo` 或 `dev` 分支。
|
- 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」:若有從 main 開出來的 hotfix 分支直接併回 main 的例外情況(需使用者明確指示),**必須**同步將 main 分支 merge 回 `demo` 與 `dev` 分支,維持全環境版本一致。
|
||||||
@@ -151,7 +151,7 @@ class RoleController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function getGroupedPermissions()
|
private function getGroupedPermissions()
|
||||||
{
|
{
|
||||||
$allPermissions = Permission::orderBy('name')->get();
|
$allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get();
|
||||||
$grouped = [];
|
$grouped = [];
|
||||||
|
|
||||||
foreach ($allPermissions as $permission) {
|
foreach ($allPermissions as $permission) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CoreService implements CoreServiceInterface
|
|||||||
*/
|
*/
|
||||||
public function getUsersByIds(array $ids): Collection
|
public function getUsersByIds(array $ids): Collection
|
||||||
{
|
{
|
||||||
return User::whereIn('id', $ids)->get();
|
return User::select('id', 'name')->whereIn('id', $ids)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +37,7 @@ class CoreService implements CoreServiceInterface
|
|||||||
*/
|
*/
|
||||||
public function getAllUsers(): Collection
|
public function getAllUsers(): Collection
|
||||||
{
|
{
|
||||||
return User::all();
|
return User::select('id', 'name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ensureSystemUserExists()
|
public function ensureSystemUserExists()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class AccountPayableService
|
|||||||
|
|
||||||
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
||||||
->orderBy('document_number', 'desc')
|
->orderBy('document_number', 'desc')
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$latest) {
|
if (!$latest) {
|
||||||
|
|||||||
@@ -133,7 +133,10 @@ class SyncOrderAction
|
|||||||
$warehouseId,
|
$warehouseId,
|
||||||
$qty,
|
$qty,
|
||||||
"POS Order: " . $order->external_order_id,
|
"POS Order: " . $order->external_order_id,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
$order->id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ class SyncVendingOrderAction
|
|||||||
$warehouseId,
|
$warehouseId,
|
||||||
$qty,
|
$qty,
|
||||||
"Vending Order: " . $order->external_order_id,
|
"Vending Order: " . $order->external_order_id,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
$order->id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface InventoryServiceInterface
|
|||||||
* @param string|null $slot
|
* @param string|null $slot
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): 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.
|
* Get all active warehouses.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class AdjustDocController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Inventory/Adjust/Index', [
|
return Inertia::render('Inventory/Adjust/Index', [
|
||||||
'docs' => $docs,
|
'docs' => $docs,
|
||||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class CountDocController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Inventory/Count/Index', [
|
return Inertia::render('Inventory/Count/Index', [
|
||||||
'docs' => $docs,
|
'docs' => $docs,
|
||||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class InventoryController extends Controller
|
|||||||
'inventories.lastIncomingTransaction',
|
'inventories.lastIncomingTransaction',
|
||||||
'inventories.lastOutgoingTransaction'
|
'inventories.lastOutgoingTransaction'
|
||||||
]);
|
]);
|
||||||
$allProducts = Product::with('category')->get();
|
$allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
|
||||||
|
|
||||||
// 1. 準備 availableProducts
|
// 1. 準備 availableProducts
|
||||||
$availableProducts = $allProducts->map(function ($product) {
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
@@ -167,8 +167,8 @@ class InventoryController extends Controller
|
|||||||
public function create(Warehouse $warehouse)
|
public function create(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
// ... (unchanged) ...
|
// ... (unchanged) ...
|
||||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
$products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||||
->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||||
->get()
|
->get()
|
||||||
->map(function ($product) {
|
->map(function ($product) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ class ProductController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
$categories = Category::where('is_active', true)->get();
|
$categories = Category::select('id', 'name')->where('is_active', true)->get();
|
||||||
|
|
||||||
return Inertia::render('Product/Index', [
|
return Inertia::render('Product/Index', [
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
'categories' => $categories->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
'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']),
|
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -172,8 +172,8 @@ class ProductController extends Controller
|
|||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Product/Create', [
|
return Inertia::render('Product/Create', [
|
||||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,8 +231,8 @@ class ProductController extends Controller
|
|||||||
'wholesale_price' => (float) $product->wholesale_price,
|
'wholesale_price' => (float) $product->wholesale_price,
|
||||||
'is_active' => (bool) $product->is_active,
|
'is_active' => (bool) $product->is_active,
|
||||||
],
|
],
|
||||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class SafetyStockController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Warehouse $warehouse)
|
public function index(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
$allProducts = Product::with(['category', 'baseUnit'])->get();
|
$allProducts = Product::select('id', 'name', 'category_id', 'base_unit_id')->with(['category:id,name', 'baseUnit:id,name'])->get();
|
||||||
|
|
||||||
// 準備可選商品列表
|
// 準備可選商品列表
|
||||||
$availableProducts = $allProducts->map(function ($product) {
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class TransferOrderController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Inventory/Transfer/Index', [
|
return Inertia::render('Inventory/Transfer/Index', [
|
||||||
'orders' => $orders,
|
'orders' => $orders,
|
||||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||||
'filters' => $request->only(['search', 'warehouse_id', 'per_page']),
|
'filters' => $request->only(['search', 'warehouse_id', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,16 +44,23 @@ class AdjustService
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($countDoc->items as $item) {
|
foreach ($countDoc->items as $item) {
|
||||||
if (abs($item->diff_qty) < 0.0001) continue;
|
if (abs($item->diff_qty) < 0.0001) continue;
|
||||||
|
|
||||||
$adjDoc->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'adjust_doc_id' => $adjDoc->id,
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
'qty_before' => $item->system_qty,
|
'qty_before' => $item->system_qty,
|
||||||
'adjust_qty' => $item->diff_qty,
|
'adjust_qty' => $item->diff_qty,
|
||||||
'notes' => "盤點差異: " . $item->diff_qty,
|
'notes' => "盤點差異: " . $item->diff_qty,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
InventoryAdjustItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $adjDoc;
|
return $adjDoc;
|
||||||
@@ -84,25 +91,35 @@ class AdjustService
|
|||||||
|
|
||||||
$doc->items()->delete();
|
$doc->items()->delete();
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
|
$productIds = collect($itemsData)->pluck('product_id')->unique()->toArray();
|
||||||
|
$products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||||
|
|
||||||
|
// 批次取得當前庫存
|
||||||
|
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||||
|
->whereIn('product_id', $productIds)
|
||||||
|
->get();
|
||||||
|
|
||||||
foreach ($itemsData as $data) {
|
foreach ($itemsData as $data) {
|
||||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
$inventory = $inventories->where('product_id', $data['product_id'])
|
||||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
|
||||||
->where('product_id', $data['product_id'])
|
|
||||||
->where('batch_number', $data['batch_number'] ?? null)
|
->where('batch_number', $data['batch_number'] ?? null)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||||
|
|
||||||
$newItem = $doc->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'adjust_doc_id' => $doc->id,
|
||||||
'product_id' => $data['product_id'],
|
'product_id' => $data['product_id'],
|
||||||
'batch_number' => $data['batch_number'] ?? null,
|
'batch_number' => $data['batch_number'] ?? null,
|
||||||
'qty_before' => $qtyBefore,
|
'qty_before' => $qtyBefore,
|
||||||
'adjust_qty' => $data['adjust_qty'],
|
'adjust_qty' => $data['adjust_qty'],
|
||||||
'notes' => $data['notes'] ?? null,
|
'notes' => $data['notes'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
// 更新日誌中的品項列表
|
// 更新日誌中的品項列表
|
||||||
$productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
|
$productName = $products->get($data['product_id'])?->name ?? '未知商品';
|
||||||
$found = false;
|
$found = false;
|
||||||
foreach ($updatedItems as $idx => $ui) {
|
foreach ($updatedItems as $idx => $ui) {
|
||||||
if ($ui['product_name'] === $productName && $ui['new'] === null) {
|
if ($ui['product_name'] === $productName && $ui['new'] === null) {
|
||||||
@@ -126,6 +143,10 @@ class AdjustService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
InventoryAdjustItem::insert($itemsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理沒被更新到的舊品項 (即真正被刪除的)
|
// 清理沒被更新到的舊品項 (即真正被刪除的)
|
||||||
$finalUpdatedItems = [];
|
$finalUpdatedItems = [];
|
||||||
foreach ($updatedItems as $ui) {
|
foreach ($updatedItems as $ui) {
|
||||||
@@ -162,11 +183,20 @@ class AdjustService
|
|||||||
foreach ($doc->items as $item) {
|
foreach ($doc->items as $item) {
|
||||||
if ($item->adjust_qty == 0) continue;
|
if ($item->adjust_qty == 0) continue;
|
||||||
|
|
||||||
$inventory = Inventory::firstOrNew([
|
// 補上 lockForUpdate() 防止併發衝突
|
||||||
|
$inventory = Inventory::where([
|
||||||
'warehouse_id' => $doc->warehouse_id,
|
'warehouse_id' => $doc->warehouse_id,
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
]);
|
])->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if (!$inventory) {
|
||||||
|
$inventory = new Inventory([
|
||||||
|
'warehouse_id' => $doc->warehouse_id,
|
||||||
|
'product_id' => $item->product_id,
|
||||||
|
'batch_number' => $item->batch_number,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
|
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
|
||||||
if (!$inventory->exists) {
|
if (!$inventory->exists) {
|
||||||
|
|||||||
@@ -47,14 +47,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
|
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
|
||||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||||
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
|
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
|
||||||
? (float) $itemData['subtotal']
|
? (float) $itemData['subtotal']
|
||||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||||
|
|
||||||
// Create GR Item
|
$itemsToInsert[] = [
|
||||||
$grItem = new GoodsReceiptItem([
|
'goods_receipt_id' => $goodsReceipt->id,
|
||||||
'product_id' => $itemData['product_id'],
|
'product_id' => $itemData['product_id'],
|
||||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||||
'quantity_received' => $itemData['quantity_received'],
|
'quantity_received' => $itemData['quantity_received'],
|
||||||
@@ -62,8 +63,9 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$goodsReceipt->items()->save($grItem);
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
$product = $products->get($itemData['product_id']);
|
$product = $products->get($itemData['product_id']);
|
||||||
$diff['added'][] = [
|
$diff['added'][] = [
|
||||||
@@ -76,6 +78,10 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
GoodsReceiptItem::insert($itemsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 手動發送高品質日誌(包含品項明細)
|
// 4. 手動發送高品質日誌(包含品項明細)
|
||||||
activity()
|
activity()
|
||||||
->performedOn($goodsReceipt)
|
->performedOn($goodsReceipt)
|
||||||
@@ -146,13 +152,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
if (isset($data['items'])) {
|
if (isset($data['items'])) {
|
||||||
$goodsReceipt->items()->delete();
|
$goodsReceipt->items()->delete();
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||||
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
|
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
|
||||||
? (float) $itemData['subtotal']
|
? (float) $itemData['subtotal']
|
||||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||||
|
|
||||||
$grItem = new GoodsReceiptItem([
|
$itemsToInsert[] = [
|
||||||
|
'goods_receipt_id' => $goodsReceipt->id,
|
||||||
'product_id' => $itemData['product_id'],
|
'product_id' => $itemData['product_id'],
|
||||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||||
'quantity_received' => $itemData['quantity_received'],
|
'quantity_received' => $itemData['quantity_received'],
|
||||||
@@ -160,8 +168,13 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$goodsReceipt->items()->save($grItem);
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($itemsToInsert)) {
|
||||||
|
GoodsReceiptItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,11 +261,14 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
*/
|
*/
|
||||||
public function submit(GoodsReceipt $goodsReceipt)
|
public function submit(GoodsReceipt $goodsReceipt)
|
||||||
{
|
{
|
||||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
|
||||||
throw new \Exception('只有草稿或被退回的進貨單可以確認點收。');
|
|
||||||
}
|
|
||||||
|
|
||||||
return DB::transaction(function () use ($goodsReceipt) {
|
return DB::transaction(function () use ($goodsReceipt) {
|
||||||
|
// Pessimistic locking to prevent double submission
|
||||||
|
$goodsReceipt = GoodsReceipt::lockForUpdate()->find($goodsReceipt->id);
|
||||||
|
|
||||||
|
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
||||||
|
throw new \Exception('只有草稿或被退回的進貨單可以確認點收。');
|
||||||
|
}
|
||||||
|
|
||||||
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
|
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
|
||||||
$goodsReceipt->save();
|
$goodsReceipt->save();
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
{
|
{
|
||||||
public function getAllWarehouses()
|
public function getAllWarehouses()
|
||||||
{
|
{
|
||||||
return Warehouse::all();
|
return Warehouse::select('id', 'name', 'code', 'type')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
|
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
|
||||||
@@ -38,12 +38,14 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
|
|
||||||
public function getAllProducts()
|
public function getAllProducts()
|
||||||
{
|
{
|
||||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
return Product::select('id', 'name', 'code', 'base_unit_id', 'large_unit_id')
|
||||||
|
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||||
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUnits()
|
public function getUnits()
|
||||||
{
|
{
|
||||||
return \App\Modules\Inventory\Models\Unit::all();
|
return \App\Modules\Inventory\Models\Unit::select('id', 'name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getInventoriesByIds(array $ids, array $with = [])
|
public function getInventoriesByIds(array $ids, array $with = [])
|
||||||
@@ -85,9 +87,9 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
return $stock >= $quantity;
|
return $stock >= $quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): 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
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) {
|
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId) {
|
||||||
$query = Inventory::where('product_id', $productId)
|
$query = Inventory::where('product_id', $productId)
|
||||||
->where('warehouse_id', $warehouseId)
|
->where('warehouse_id', $warehouseId)
|
||||||
->where('quantity', '>', 0);
|
->where('quantity', '>', 0);
|
||||||
@@ -96,7 +98,8 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
$query->where('location', $slot);
|
$query->where('location', $slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
$inventories = $query->orderBy('arrival_date', 'asc')
|
$inventories = $query->lockForUpdate()
|
||||||
|
->orderBy('arrival_date', 'asc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$remainingToDecrease = $quantity;
|
$remainingToDecrease = $quantity;
|
||||||
@@ -105,7 +108,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
if ($remainingToDecrease <= 0) break;
|
if ($remainingToDecrease <= 0) break;
|
||||||
|
|
||||||
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
||||||
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason);
|
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
|
||||||
$remainingToDecrease -= $decreaseAmount;
|
$remainingToDecrease -= $decreaseAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason);
|
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId);
|
||||||
} else {
|
} else {
|
||||||
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,15 +53,22 @@ class StoreRequisitionService
|
|||||||
// 靜默建立以抑制自動日誌
|
// 靜默建立以抑制自動日誌
|
||||||
$requisition->saveQuietly();
|
$requisition->saveQuietly();
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
|
$productIds = collect($items)->pluck('product_id')->unique()->toArray();
|
||||||
|
$products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||||
|
|
||||||
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$requisition->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'store_requisition_id' => $requisition->id,
|
||||||
'product_id' => $item['product_id'],
|
'product_id' => $item['product_id'],
|
||||||
'requested_qty' => $item['requested_qty'],
|
'requested_qty' => $item['requested_qty'],
|
||||||
'remark' => $item['remark'] ?? null,
|
'remark' => $item['remark'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
$product = \App\Modules\Inventory\Models\Product::find($item['product_id']);
|
$product = $products->get($item['product_id']);
|
||||||
$diff['added'][] = [
|
$diff['added'][] = [
|
||||||
'product_name' => $product?->name ?? '未知商品',
|
'product_name' => $product?->name ?? '未知商品',
|
||||||
'new' => [
|
'new' => [
|
||||||
@@ -70,6 +77,7 @@ class StoreRequisitionService
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
StoreRequisitionItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 如果需直接提交,觸發通知
|
// 如果需直接提交,觸發通知
|
||||||
if ($submitImmediately) {
|
if ($submitImmediately) {
|
||||||
@@ -179,13 +187,18 @@ class StoreRequisitionService
|
|||||||
|
|
||||||
// 儲存實際變動
|
// 儲存實際變動
|
||||||
$requisition->items()->delete();
|
$requisition->items()->delete();
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$requisition->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'store_requisition_id' => $requisition->id,
|
||||||
'product_id' => $item['product_id'],
|
'product_id' => $item['product_id'],
|
||||||
'requested_qty' => $item['requested_qty'],
|
'requested_qty' => $item['requested_qty'],
|
||||||
'remark' => $item['remark'] ?? null,
|
'remark' => $item['remark'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
StoreRequisitionItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 檢查是否有任何變動 (主表或明細)
|
// 檢查是否有任何變動 (主表或明細)
|
||||||
$isDirty = $requisition->isDirty();
|
$isDirty = $requisition->isDirty();
|
||||||
@@ -314,6 +327,7 @@ class StoreRequisitionService
|
|||||||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||||||
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
||||||
->where('product_id', $reqItem->product_id)
|
->where('product_id', $reqItem->product_id)
|
||||||
|
->lockForUpdate() // 補上鎖定
|
||||||
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
||||||
->value('available') ?? 0;
|
->value('available') ?? 0;
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,12 @@ class TransferService
|
|||||||
return [$key => $item];
|
return [$key => $item];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 釋放舊明細的預扣庫存
|
// 釋放舊明細的預扣庫存 (必須加鎖,防止並發更新時數量出錯)
|
||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
if ($inv) {
|
if ($inv) {
|
||||||
$inv->releaseReservedQuantity($item->quantity);
|
$inv->releaseReservedQuantity($item->quantity);
|
||||||
@@ -91,42 +92,69 @@ class TransferService
|
|||||||
'updated' => [],
|
'updated' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 先刪除舊明細
|
||||||
$order->items()->delete();
|
$order->items()->delete();
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
$newItemsKeys = [];
|
$newItemsKeys = [];
|
||||||
|
|
||||||
|
// 1. 批量收集待插入的明細數據
|
||||||
foreach ($itemsData as $data) {
|
foreach ($itemsData as $data) {
|
||||||
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
||||||
$newItemsKeys[] = $key;
|
$newItemsKeys[] = $key;
|
||||||
|
|
||||||
$item = $order->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'transfer_order_id' => $order->id,
|
||||||
'product_id' => $data['product_id'],
|
'product_id' => $data['product_id'],
|
||||||
'batch_number' => $data['batch_number'] ?? null,
|
'batch_number' => $data['batch_number'] ?? null,
|
||||||
'quantity' => $data['quantity'],
|
'quantity' => $data['quantity'],
|
||||||
'position' => $data['position'] ?? null,
|
'position' => $data['position'] ?? null,
|
||||||
'notes' => $data['notes'] ?? null,
|
'notes' => $data['notes'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$item->load('product');
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// 增加新明細的預扣庫存
|
// 2. 執行批量寫入 (提升效能:100 筆明細只需 1 次寫入)
|
||||||
$inv = Inventory::firstOrCreate(
|
if (!empty($itemsToInsert)) {
|
||||||
[
|
InventoryTransferItem::insert($itemsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 重新載入明細進行預扣處理與 Diff 計算 (因 insert 不返回 Model)
|
||||||
|
$order->load(['items.product.baseUnit']);
|
||||||
|
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||||||
|
|
||||||
|
// 增加新明細的預扣庫存 (使用 lockForUpdate 確保並發安全)
|
||||||
|
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
|
->where('product_id', $item->product_id)
|
||||||
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$inv) {
|
||||||
|
$inv = Inventory::create([
|
||||||
'warehouse_id' => $order->from_warehouse_id,
|
'warehouse_id' => $order->from_warehouse_id,
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
],
|
|
||||||
[
|
|
||||||
'quantity' => 0,
|
'quantity' => 0,
|
||||||
'unit_cost' => 0,
|
'unit_cost' => 0,
|
||||||
'total_value' => 0,
|
'total_value' => 0,
|
||||||
]
|
]);
|
||||||
);
|
$inv = $inv->fresh()->lockForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
$inv->reserveQuantity($item->quantity);
|
$inv->reserveQuantity($item->quantity);
|
||||||
|
|
||||||
|
// 計算 Diff 用於日誌
|
||||||
|
$data = collect($itemsData)->first(fn($d) => $d['product_id'] == $item->product_id && ($d['batch_number'] ?? '') == ($item->batch_number ?? ''));
|
||||||
|
|
||||||
if ($oldItemsMap->has($key)) {
|
if ($oldItemsMap->has($key)) {
|
||||||
$oldItem = $oldItemsMap->get($key);
|
$oldItem = $oldItemsMap->get($key);
|
||||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
if ((float)$oldItem->quantity !== (float)$item->quantity ||
|
||||||
$oldItem->notes !== ($data['notes'] ?? null) ||
|
$oldItem->notes !== $item->notes ||
|
||||||
$oldItem->position !== ($data['position'] ?? null)) {
|
$oldItem->position !== $item->position) {
|
||||||
|
|
||||||
$diff['updated'][] = [
|
$diff['updated'][] = [
|
||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
@@ -137,7 +165,7 @@ class TransferService
|
|||||||
'notes' => $oldItem->notes,
|
'notes' => $oldItem->notes,
|
||||||
],
|
],
|
||||||
'new' => [
|
'new' => [
|
||||||
'quantity' => (float)$data['quantity'],
|
'quantity' => (float)$item->quantity,
|
||||||
'position' => $item->position,
|
'position' => $item->position,
|
||||||
'notes' => $item->notes,
|
'notes' => $item->notes,
|
||||||
]
|
]
|
||||||
@@ -158,8 +186,8 @@ class TransferService
|
|||||||
foreach ($oldItemsMap as $key => $oldItem) {
|
foreach ($oldItemsMap as $key => $oldItem) {
|
||||||
if (!in_array($key, $newItemsKeys)) {
|
if (!in_array($key, $newItemsKeys)) {
|
||||||
$diff['removed'][] = [
|
$diff['removed'][] = [
|
||||||
'product_name' => $oldItem->product->name,
|
'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})",
|
||||||
'unit_name' => $oldItem->product->baseUnit?->name,
|
'unit_name' => $oldItem->product?->baseUnit?->name,
|
||||||
'old' => [
|
'old' => [
|
||||||
'quantity' => (float)$oldItem->quantity,
|
'quantity' => (float)$oldItem->quantity,
|
||||||
'notes' => $oldItem->notes,
|
'notes' => $oldItem->notes,
|
||||||
@@ -179,9 +207,6 @@ class TransferService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
||||||
*
|
|
||||||
* 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched
|
|
||||||
* 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯)
|
|
||||||
*/
|
*/
|
||||||
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
||||||
{
|
{
|
||||||
@@ -194,18 +219,16 @@ class TransferService
|
|||||||
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
||||||
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
||||||
|
|
||||||
$outType = '調撥出庫';
|
|
||||||
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
|
||||||
|
|
||||||
$itemsDiff = [];
|
$itemsDiff = [];
|
||||||
|
|
||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
if ($item->quantity <= 0) continue;
|
if ($item->quantity <= 0) continue;
|
||||||
|
|
||||||
// 1. 處理來源倉 (扣除)
|
// 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣
|
||||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||||
@@ -235,11 +258,11 @@ class TransferService
|
|||||||
|
|
||||||
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
||||||
|
|
||||||
// 2. 處理目的倉/在途倉 (增加)
|
// 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition
|
||||||
// 獲取目的倉異動前的庫存數(若無則為 0)
|
|
||||||
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
|
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||||
|
|
||||||
@@ -310,7 +333,6 @@ class TransferService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
||||||
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
|
|
||||||
*/
|
*/
|
||||||
public function receive(InventoryTransferOrder $order, int $userId): void
|
public function receive(InventoryTransferOrder $order, int $userId): void
|
||||||
{
|
{
|
||||||
@@ -333,10 +355,11 @@ class TransferService
|
|||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
if ($item->quantity <= 0) continue;
|
if ($item->quantity <= 0) continue;
|
||||||
|
|
||||||
// 1. 在途倉扣除
|
// 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣
|
||||||
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
||||||
@@ -359,10 +382,11 @@ class TransferService
|
|||||||
|
|
||||||
$transitAfter = $transitBefore - (float) $item->quantity;
|
$transitAfter = $transitBefore - (float) $item->quantity;
|
||||||
|
|
||||||
// 2. 目的倉增加
|
// 2. 目的倉增加 - 同樣需要鎖定
|
||||||
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
|
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||||
|
|
||||||
@@ -440,6 +464,7 @@ class TransferService
|
|||||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->where('batch_number', $item->batch_number)
|
||||||
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
if ($inv) {
|
if ($inv) {
|
||||||
$inv->releaseReservedQuantity($item->quantity);
|
$inv->releaseReservedQuantity($item->quantity);
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ class TurnoverService
|
|||||||
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
|
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
|
||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
|
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereIn('inventory_transactions.reference_type', [
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||||
|
])->orWhereNull('inventory_transactions.reference_type');
|
||||||
|
})
|
||||||
->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
|
->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
|
||||||
->groupBy('inventories.product_id');
|
->groupBy('inventories.product_id');
|
||||||
|
|
||||||
@@ -87,6 +93,12 @@ class TurnoverService
|
|||||||
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
|
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
|
||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->where('inventory_transactions.type', '出庫')
|
->where('inventory_transactions.type', '出庫')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereIn('inventory_transactions.reference_type', [
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||||
|
])->orWhereNull('inventory_transactions.reference_type');
|
||||||
|
})
|
||||||
->groupBy('inventories.product_id');
|
->groupBy('inventories.product_id');
|
||||||
|
|
||||||
if ($warehouseId) {
|
if ($warehouseId) {
|
||||||
@@ -199,6 +211,12 @@ class TurnoverService
|
|||||||
// Get IDs of products sold in last 90 days
|
// Get IDs of products sold in last 90 days
|
||||||
$soldProductIds = InventoryTransaction::query()
|
$soldProductIds = InventoryTransaction::query()
|
||||||
->where('type', '出庫')
|
->where('type', '出庫')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereIn('reference_type', [
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||||
|
])->orWhereNull('reference_type');
|
||||||
|
})
|
||||||
->where('actual_time', '>=', $ninetyDaysAgo)
|
->where('actual_time', '>=', $ninetyDaysAgo)
|
||||||
->distinct()
|
->distinct()
|
||||||
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
|
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
|
||||||
@@ -214,6 +232,12 @@ class TurnoverService
|
|||||||
$soldProductIdsQuery = DB::table('inventory_transactions')
|
$soldProductIdsQuery = DB::table('inventory_transactions')
|
||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->where('inventory_transactions.type', '出庫')
|
->where('inventory_transactions.type', '出庫')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereIn('inventory_transactions.reference_type', [
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||||
|
])->orWhereNull('inventory_transactions.reference_type');
|
||||||
|
})
|
||||||
->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
|
->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
|
||||||
->select('inventories.product_id')
|
->select('inventories.product_id')
|
||||||
->distinct();
|
->distinct();
|
||||||
@@ -236,6 +260,12 @@ class TurnoverService
|
|||||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||||
->where('inventory_transactions.type', '出庫')
|
->where('inventory_transactions.type', '出庫')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereIn('inventory_transactions.reference_type', [
|
||||||
|
\App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||||
|
])->orWhereNull('inventory_transactions.reference_type');
|
||||||
|
})
|
||||||
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
|
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
|
||||||
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
|
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
|
||||||
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
|
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class PurchaseOrderController extends Controller
|
|||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
// 1. 獲取廠商(無關聯)
|
// 1. 獲取廠商(無關聯)
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
|
|
||||||
// 2. 手動注入:獲取 Pivot 資料
|
// 2. 手動注入:獲取 Pivot 資料
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
@@ -254,17 +254,21 @@ class PurchaseOrderController extends Controller
|
|||||||
$productIds = collect($validated['items'])->pluck('productId')->unique()->toArray();
|
$productIds = collect($validated['items'])->pluck('productId')->unique()->toArray();
|
||||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($validated['items'] as $item) {
|
foreach ($validated['items'] as $item) {
|
||||||
// 反算單價
|
// 反算單價
|
||||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||||
|
|
||||||
$order->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_order_id' => $order->id,
|
||||||
'product_id' => $item['productId'],
|
'product_id' => $item['productId'],
|
||||||
'quantity' => $item['quantity'],
|
'quantity' => $item['quantity'],
|
||||||
'unit_id' => $item['unitId'] ?? null,
|
'unit_id' => $item['unitId'] ?? null,
|
||||||
'unit_price' => $unitPrice,
|
'unit_price' => $unitPrice,
|
||||||
'subtotal' => $item['subtotal'],
|
'subtotal' => $item['subtotal'],
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
$product = $products->get($item['productId']);
|
$product = $products->get($item['productId']);
|
||||||
$diff['added'][] = [
|
$diff['added'][] = [
|
||||||
@@ -275,6 +279,7 @@ class PurchaseOrderController extends Controller
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 手動發送高品質日誌(包含品項明細)
|
// 手動發送高品質日誌(包含品項明細)
|
||||||
activity()
|
activity()
|
||||||
@@ -379,7 +384,7 @@ class PurchaseOrderController extends Controller
|
|||||||
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
||||||
|
|
||||||
// 2. 獲取廠商與商品(與 create 邏輯一致)
|
// 2. 獲取廠商與商品(與 create 邏輯一致)
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
||||||
$productIds = $pivots->pluck('product_id')->unique()->toArray();
|
$productIds = $pivots->pluck('product_id')->unique()->toArray();
|
||||||
@@ -468,7 +473,8 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, $id)
|
public function update(Request $request, $id)
|
||||||
{
|
{
|
||||||
$order = PurchaseOrder::findOrFail($id);
|
// 加上 lockForUpdate() 防止併發修改
|
||||||
|
$order = PurchaseOrder::lockForUpdate()->findOrFail($id);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'vendor_id' => 'required|exists:vendors,id',
|
'vendor_id' => 'required|exists:vendors,id',
|
||||||
@@ -572,20 +578,23 @@ class PurchaseOrderController extends Controller
|
|||||||
// 同步項目(原始邏輯)
|
// 同步項目(原始邏輯)
|
||||||
$order->items()->delete();
|
$order->items()->delete();
|
||||||
|
|
||||||
$newItemsData = [];
|
$itemsToInsert = [];
|
||||||
foreach ($validated['items'] as $item) {
|
foreach ($validated['items'] as $item) {
|
||||||
// 反算單價
|
// 反算單價
|
||||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||||
|
|
||||||
$newItem = $order->items()->create([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_order_id' => $order->id,
|
||||||
'product_id' => $item['productId'],
|
'product_id' => $item['productId'],
|
||||||
'quantity' => $item['quantity'],
|
'quantity' => $item['quantity'],
|
||||||
'unit_id' => $item['unitId'] ?? null,
|
'unit_id' => $item['unitId'] ?? null,
|
||||||
'unit_price' => $unitPrice,
|
'unit_price' => $unitPrice,
|
||||||
'subtotal' => $item['subtotal'],
|
'subtotal' => $item['subtotal'],
|
||||||
]);
|
'created_at' => now(),
|
||||||
$newItemsData[] = $newItem;
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 3. 計算項目差異
|
// 3. 計算項目差異
|
||||||
$itemDiffs = [
|
$itemDiffs = [
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class PurchaseReturnController extends Controller
|
|||||||
{
|
{
|
||||||
// 取得可用的倉庫與廠商資料供前端選單使用
|
// 取得可用的倉庫與廠商資料供前端選單使用
|
||||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
|
|
||||||
// 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致)
|
// 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致)
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
@@ -157,7 +157,7 @@ class PurchaseReturnController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||||
$vendors = Vendor::all();
|
$vendors = Vendor::select('id', 'name')->get();
|
||||||
|
|
||||||
// 手動注入:獲取廠商商品 (與 create 邏輯一致)
|
// 手動注入:獲取廠商商品 (與 create 邏輯一致)
|
||||||
$vendorIds = $vendors->pluck('id')->toArray();
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
|
|||||||
@@ -33,20 +33,23 @@ class PurchaseReturnService
|
|||||||
|
|
||||||
$purchaseReturn = PurchaseReturn::create($data);
|
$purchaseReturn = PurchaseReturn::create($data);
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
||||||
$totalAmount += $amount;
|
$totalAmount += $amount;
|
||||||
|
|
||||||
$prItem = new PurchaseReturnItem([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_return_id' => $purchaseReturn->id,
|
||||||
'product_id' => $itemData['product_id'],
|
'product_id' => $itemData['product_id'],
|
||||||
'quantity_returned' => $itemData['quantity_returned'],
|
'quantity_returned' => $itemData['quantity_returned'],
|
||||||
'unit_price' => $itemData['unit_price'],
|
'unit_price' => $itemData['unit_price'],
|
||||||
'total_amount' => $amount,
|
'total_amount' => $amount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
$purchaseReturn->items()->save($prItem);
|
];
|
||||||
}
|
}
|
||||||
|
PurchaseReturnItem::insert($itemsToInsert);
|
||||||
|
|
||||||
// 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount)
|
// 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount)
|
||||||
$taxAmount = $data['tax_amount'] ?? 0;
|
$taxAmount = $data['tax_amount'] ?? 0;
|
||||||
@@ -87,19 +90,23 @@ class PurchaseReturnService
|
|||||||
$purchaseReturn->items()->delete();
|
$purchaseReturn->items()->delete();
|
||||||
$totalAmount = 0;
|
$totalAmount = 0;
|
||||||
|
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
||||||
$totalAmount += $amount;
|
$totalAmount += $amount;
|
||||||
|
|
||||||
$prItem = new PurchaseReturnItem([
|
$itemsToInsert[] = [
|
||||||
|
'purchase_return_id' => $purchaseReturn->id,
|
||||||
'product_id' => $itemData['product_id'],
|
'product_id' => $itemData['product_id'],
|
||||||
'quantity_returned' => $itemData['quantity_returned'],
|
'quantity_returned' => $itemData['quantity_returned'],
|
||||||
'unit_price' => $itemData['unit_price'],
|
'unit_price' => $itemData['unit_price'],
|
||||||
'total_amount' => $amount,
|
'total_amount' => $amount,
|
||||||
'batch_number' => $itemData['batch_number'] ?? null,
|
'batch_number' => $itemData['batch_number'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
$purchaseReturn->items()->save($prItem);
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
PurchaseReturnItem::insert($itemsToInsert);
|
||||||
|
|
||||||
$taxAmount = $purchaseReturn->tax_amount;
|
$taxAmount = $purchaseReturn->tax_amount;
|
||||||
$purchaseReturn->update([
|
$purchaseReturn->update([
|
||||||
@@ -117,11 +124,14 @@ class PurchaseReturnService
|
|||||||
*/
|
*/
|
||||||
public function submit(PurchaseReturn $purchaseReturn)
|
public function submit(PurchaseReturn $purchaseReturn)
|
||||||
{
|
{
|
||||||
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
|
|
||||||
throw new Exception('只有草稿狀態的退回單可以提交。');
|
|
||||||
}
|
|
||||||
|
|
||||||
return DB::transaction(function () use ($purchaseReturn) {
|
return DB::transaction(function () use ($purchaseReturn) {
|
||||||
|
// 加上 lockForUpdate() 防止併發提交
|
||||||
|
$purchaseReturn = PurchaseReturn::lockForUpdate()->find($purchaseReturn->id);
|
||||||
|
|
||||||
|
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
|
||||||
|
throw new Exception('只有草稿狀態的退回單可以提交。');
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
|
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
|
||||||
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
|
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
|
||||||
$purchaseReturn->saveQuietly();
|
$purchaseReturn->saveQuietly();
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ class ProductionOrderController extends Controller
|
|||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'product_id' => 'required',
|
'product_id' => 'required',
|
||||||
'status' => 'nullable|in:draft,completed',
|
'status' => 'nullable|in:draft,pending,completed',
|
||||||
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable',
|
'warehouse_id' => 'required',
|
||||||
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric',
|
'output_quantity' => 'required|numeric|min:0.01',
|
||||||
'items' => 'nullable|array',
|
'items' => 'nullable|array',
|
||||||
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable',
|
'items.*.inventory_id' => 'required',
|
||||||
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric',
|
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
||||||
];
|
];
|
||||||
|
|
||||||
$validated = $request->validate($rules);
|
$validated = $request->validate($rules);
|
||||||
@@ -159,7 +159,7 @@ class ProductionOrderController extends Controller
|
|||||||
'production_date' => $request->production_date,
|
'production_date' => $request->production_date,
|
||||||
'expiry_date' => $request->expiry_date,
|
'expiry_date' => $request->expiry_date,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿
|
'status' => $status ?: ProductionOrder::STATUS_DRAFT,
|
||||||
'remark' => $request->remark,
|
'remark' => $request->remark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -170,14 +170,18 @@ class ProductionOrderController extends Controller
|
|||||||
|
|
||||||
// 2. 處理明細
|
// 2. 處理明細
|
||||||
if (!empty($request->items)) {
|
if (!empty($request->items)) {
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($request->items as $item) {
|
foreach ($request->items as $item) {
|
||||||
ProductionOrderItem::create([
|
$itemsToInsert[] = [
|
||||||
'production_order_id' => $productionOrder->id,
|
'production_order_id' => $productionOrder->id,
|
||||||
'inventory_id' => $item['inventory_id'],
|
'inventory_id' => $item['inventory_id'],
|
||||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
'unit_id' => $item['unit_id'] ?? null,
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
ProductionOrderItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -380,14 +384,18 @@ class ProductionOrderController extends Controller
|
|||||||
$productionOrder->items()->delete();
|
$productionOrder->items()->delete();
|
||||||
|
|
||||||
if (!empty($request->items)) {
|
if (!empty($request->items)) {
|
||||||
|
$itemsToInsert = [];
|
||||||
foreach ($request->items as $item) {
|
foreach ($request->items as $item) {
|
||||||
ProductionOrderItem::create([
|
$itemsToInsert[] = [
|
||||||
'production_order_id' => $productionOrder->id,
|
'production_order_id' => $productionOrder->id,
|
||||||
'inventory_id' => $item['inventory_id'],
|
'inventory_id' => $item['inventory_id'],
|
||||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
'unit_id' => $item['unit_id'] ?? null,
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
ProductionOrderItem::insert($itemsToInsert);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,9 +414,30 @@ class ProductionOrderController extends Controller
|
|||||||
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
|
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 送審前的資料完整性驗證
|
||||||
|
if ($productionOrder->status === ProductionOrder::STATUS_DRAFT && $newStatus === ProductionOrder::STATUS_PENDING) {
|
||||||
|
if (!$productionOrder->output_quantity || $productionOrder->output_quantity <= 0) {
|
||||||
|
return back()->with('error', '送審工單前,必須先編輯填寫「生產數量」');
|
||||||
|
}
|
||||||
|
if (!$productionOrder->warehouse_id) {
|
||||||
|
return back()->with('error', '送審工單前,必須先編輯選擇「預計入庫倉庫」');
|
||||||
|
}
|
||||||
|
if ($productionOrder->items()->count() === 0) {
|
||||||
|
return back()->with('error', '送審工單前,請至少新增一項原物料明細');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($newStatus, $productionOrder, $request) {
|
DB::transaction(function () use ($newStatus, $productionOrder, $request) {
|
||||||
|
// 使用鎖定重新獲取單據,防止併發狀態修改
|
||||||
|
$productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first();
|
||||||
|
|
||||||
$oldStatus = $productionOrder->status;
|
$oldStatus = $productionOrder->status;
|
||||||
|
|
||||||
|
// 再次檢查狀態轉移(在鎖定後)
|
||||||
|
if (!$productionOrder->canTransitionTo($newStatus)) {
|
||||||
|
throw new \Exception('不合法的狀態轉移或權限不足');
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 執行特定狀態的業務邏輯
|
// 1. 執行特定狀態的業務邏輯
|
||||||
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
|
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
|
||||||
// 開始製作 -> 扣除原料庫存
|
// 開始製作 -> 扣除原料庫存
|
||||||
@@ -428,6 +457,8 @@ class ProductionOrderController extends Controller
|
|||||||
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
|
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
|
||||||
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
|
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
|
||||||
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
|
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
|
||||||
|
$actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量
|
||||||
|
$lossReason = $request->input('loss_reason'); // 耗損原因
|
||||||
|
|
||||||
if (!$warehouseId) {
|
if (!$warehouseId) {
|
||||||
throw new \Exception('必須選擇入庫倉庫');
|
throw new \Exception('必須選擇入庫倉庫');
|
||||||
@@ -435,8 +466,14 @@ class ProductionOrderController extends Controller
|
|||||||
if (!$batchNumber) {
|
if (!$batchNumber) {
|
||||||
throw new \Exception('必須提供成品批號');
|
throw new \Exception('必須提供成品批號');
|
||||||
}
|
}
|
||||||
|
if (!$actualOutputQuantity || $actualOutputQuantity <= 0) {
|
||||||
|
throw new \Exception('實際產出數量必須大於 0');
|
||||||
|
}
|
||||||
|
if ($actualOutputQuantity > $productionOrder->output_quantity) {
|
||||||
|
throw new \Exception('實際產出數量不可大於預計產量');
|
||||||
|
}
|
||||||
|
|
||||||
// --- 新增:計算原物料投入總成本 ---
|
// --- 計算原物料投入總成本 ---
|
||||||
$totalCost = 0;
|
$totalCost = 0;
|
||||||
$items = $productionOrder->items()->with('inventory')->get();
|
$items = $productionOrder->items()->with('inventory')->get();
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
@@ -445,23 +482,25 @@ class ProductionOrderController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤)
|
// 單位成本以「實際產出數量」為分母,反映真實生產效率
|
||||||
$unitCost = $productionOrder->output_quantity > 0
|
$unitCost = $actualOutputQuantity > 0
|
||||||
? $totalCost / $productionOrder->output_quantity
|
? $totalCost / $actualOutputQuantity
|
||||||
: 0;
|
: 0;
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// 更新單據資訊:批號、效期與自動記錄生產日期
|
// 更新單據資訊:批號、效期、實際產量與耗損原因
|
||||||
$productionOrder->output_batch_number = $batchNumber;
|
$productionOrder->output_batch_number = $batchNumber;
|
||||||
$productionOrder->expiry_date = $expiryDate;
|
$productionOrder->expiry_date = $expiryDate;
|
||||||
$productionOrder->production_date = now()->toDateString();
|
$productionOrder->production_date = now()->toDateString();
|
||||||
$productionOrder->warehouse_id = $warehouseId;
|
$productionOrder->warehouse_id = $warehouseId;
|
||||||
|
$productionOrder->actual_output_quantity = $actualOutputQuantity;
|
||||||
|
$productionOrder->loss_reason = $lossReason;
|
||||||
|
|
||||||
|
// 成品入庫數量改用「實際產出數量」
|
||||||
$this->inventoryService->createInventoryRecord([
|
$this->inventoryService->createInventoryRecord([
|
||||||
'warehouse_id' => $warehouseId,
|
'warehouse_id' => $warehouseId,
|
||||||
'product_id' => $productionOrder->product_id,
|
'product_id' => $productionOrder->product_id,
|
||||||
'quantity' => $productionOrder->output_quantity,
|
'quantity' => $actualOutputQuantity,
|
||||||
'unit_cost' => $unitCost, // 傳入計算後的單位成本
|
'unit_cost' => $unitCost,
|
||||||
'batch_number' => $batchNumber,
|
'batch_number' => $batchNumber,
|
||||||
'box_number' => $productionOrder->output_box_count,
|
'box_number' => $productionOrder->output_box_count,
|
||||||
'arrival_date' => now()->toDateString(),
|
'arrival_date' => now()->toDateString(),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class ProductionOrder extends Model
|
|||||||
'product_id',
|
'product_id',
|
||||||
'warehouse_id',
|
'warehouse_id',
|
||||||
'output_quantity',
|
'output_quantity',
|
||||||
|
'actual_output_quantity',
|
||||||
|
'loss_reason',
|
||||||
'output_batch_number',
|
'output_batch_number',
|
||||||
'output_box_count',
|
'output_box_count',
|
||||||
'production_date',
|
'production_date',
|
||||||
@@ -82,6 +84,7 @@ class ProductionOrder extends Model
|
|||||||
'production_date' => 'date',
|
'production_date' => 'date',
|
||||||
'expiry_date' => 'date',
|
'expiry_date' => 'date',
|
||||||
'output_quantity' => 'decimal:2',
|
'output_quantity' => 'decimal:2',
|
||||||
|
'actual_output_quantity' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getActivitylogOptions(): LogOptions
|
public function getActivitylogOptions(): LogOptions
|
||||||
@@ -91,6 +94,8 @@ class ProductionOrder extends Model
|
|||||||
'code',
|
'code',
|
||||||
'status',
|
'status',
|
||||||
'output_quantity',
|
'output_quantity',
|
||||||
|
'actual_output_quantity',
|
||||||
|
'loss_reason',
|
||||||
'output_batch_number',
|
'output_batch_number',
|
||||||
'production_date',
|
'production_date',
|
||||||
'remark'
|
'remark'
|
||||||
|
|||||||
@@ -101,11 +101,13 @@ class SalesImportController extends Controller
|
|||||||
|
|
||||||
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
|
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
|
||||||
{
|
{
|
||||||
if ($import->status !== 'pending') {
|
return DB::transaction(function () use ($import, $inventoryService) {
|
||||||
return back()->with('error', '此批次無法確認。');
|
// 加上 lockForUpdate() 防止併發確認
|
||||||
}
|
$import = SalesImportBatch::lockForUpdate()->find($import->id);
|
||||||
|
|
||||||
DB::transaction(function () use ($import, $inventoryService) {
|
if (!$import || $import->status !== 'pending') {
|
||||||
|
throw new \Exception('此批次無法確認或已被處理。');
|
||||||
|
}
|
||||||
// 1. Prepare Aggregation
|
// 1. Prepare Aggregation
|
||||||
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
|
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
|
||||||
|
|
||||||
@@ -155,7 +157,9 @@ class SalesImportController extends Controller
|
|||||||
$deduction['quantity'],
|
$deduction['quantity'],
|
||||||
$reason,
|
$reason,
|
||||||
true, // Force deduction
|
true, // Force deduction
|
||||||
$deduction['slot'] // Location/Slot
|
$deduction['slot'], // Location/Slot
|
||||||
|
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||||
|
$import->id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 1. warehouses (倉庫)
|
||||||
|
Schema::table('warehouses', function (Blueprint $table) {
|
||||||
|
$table->index('type');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. categories (分類)
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->index('is_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. products (商品/原物料)
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
// is_active was added in a later migration, need to make sure column exists before indexing
|
||||||
|
// Same for brand if not added at start (but brand is in the create migration)
|
||||||
|
if (Schema::hasColumn('products', 'is_active')) {
|
||||||
|
$table->index('is_active');
|
||||||
|
}
|
||||||
|
$table->index('brand');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. recipes (配方/BOM)
|
||||||
|
Schema::table('recipes', function (Blueprint $table) {
|
||||||
|
$table->index('is_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. inventory_transactions (庫存異動紀錄)
|
||||||
|
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||||
|
$table->index('type');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. purchase_orders (採購單)
|
||||||
|
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('expected_delivery_date');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. production_orders (生產工單)
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. sales_orders (門市/銷售單)
|
||||||
|
Schema::table('sales_orders', function (Blueprint $table) {
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('sold_at');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('warehouses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['is_active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('products', 'is_active')) {
|
||||||
|
$table->dropIndex(['is_active']);
|
||||||
|
}
|
||||||
|
$table->dropIndex(['brand']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('recipes', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['is_active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['type']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['expected_delivery_date']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('sales_orders', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['sold_at']);
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 為生產工單新增「實際產出數量」與「耗損原因」欄位。
|
||||||
|
* 實際產出數量用於記錄完工時的真實產量(可能因耗損低於預計產量)。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->decimal('actual_output_quantity', 10, 2)
|
||||||
|
->nullable()
|
||||||
|
->after('output_quantity')
|
||||||
|
->comment('實際產出數量(預設等於 output_quantity,可於完工時調降)');
|
||||||
|
|
||||||
|
$table->string('loss_reason', 255)
|
||||||
|
->nullable()
|
||||||
|
->after('actual_output_quantity')
|
||||||
|
->comment('耗損原因說明');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('production_orders', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['actual_output_quantity', 'loss_reason']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
34
e2e/admin.spec.ts
Normal file
34
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('系統管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入角色權限管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/roles');
|
||||||
|
await expect(page.locator('h1').filter({ hasText: '角色與權限' })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增角色/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入員工帳號管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await expect(page.getByRole('heading', { name: /使用者管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增使用者/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入系統操作紀錄頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/activity-logs');
|
||||||
|
await expect(page.getByRole('heading', { name: /操作紀錄/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入系統參數設定頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/admin/settings');
|
||||||
|
await expect(page.locator('h1').filter({ hasText: '系統設定' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /存檔|儲存/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
28
e2e/finance.spec.ts
Normal file
28
e2e/finance.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('財務管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入應付帳款管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/finance/account-payables');
|
||||||
|
await expect(page.getByRole('heading', { name: /應付帳款管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入水電瓦斯費管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/utility-fees');
|
||||||
|
await expect(page.getByRole('heading', { name: /公共事業費管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入財務報表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/accounting-report');
|
||||||
|
await expect(page.getByRole('heading', { name: /會計報表/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /匯出/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Page } from '@playwright/test';
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 共用登入函式
|
* 共用登入函式
|
||||||
* 使用測試帳號登入 Star ERP 系統
|
* 使用測試帳號登入 Star ERP 系統
|
||||||
*/
|
*/
|
||||||
export async function login(page: Page, username = 'mama', password = 'mama9453') {
|
export async function login(page: Page, username = 'admin', password = 'password') {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.fill('#username', username);
|
await page.fill('#username', username);
|
||||||
await page.fill('#password', password);
|
await page.fill('#password', password);
|
||||||
await page.getByRole('button', { name: '登入系統' }).click();
|
await page.getByRole('button', { name: '登入系統' }).click();
|
||||||
// 等待儀表板載入完成
|
// 等待儀表板載入完成 (改用更穩定的側邊欄文字或 URL)
|
||||||
await page.waitForSelector('text=系統概況', { timeout: 10000 });
|
await page.waitForURL('**/');
|
||||||
|
await expect(page.getByRole('link', { name: '儀表板' }).first()).toBeVisible({ timeout: 15000 });
|
||||||
}
|
}
|
||||||
|
|||||||
14
e2e/integration.spec.ts
Normal file
14
e2e/integration.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('系統串接模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入銷貨單據串接頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/integration/sales-orders');
|
||||||
|
await expect(page.locator('h1').filter({ hasText: '銷售訂單管理' })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
e2e/inventory.spec.ts
Normal file
81
e2e/inventory.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 庫存模組端到端測試
|
||||||
|
*/
|
||||||
|
test.describe('庫存管理 - 調撥單匯入', () => {
|
||||||
|
// 登入 + 導航 + 匯入全流程需要較長時間
|
||||||
|
test.use({ actionTimeout: 15000 });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能成功匯入調撥單明細', async ({ page }) => {
|
||||||
|
// 整體測試逾時設定為 60 秒
|
||||||
|
test.setTimeout(60000);
|
||||||
|
// 1. 前往調撥單列表
|
||||||
|
await page.goto('/inventory/transfer-orders');
|
||||||
|
await expect(page.getByText('庫存調撥管理')).toBeVisible();
|
||||||
|
|
||||||
|
// 2. 等待表格載入並尋找特定的 E2E 測試單據
|
||||||
|
await page.waitForSelector('table tbody tr');
|
||||||
|
|
||||||
|
const draftRow = page.locator('tr:has-text("TRF-E2E-FINAL")').first();
|
||||||
|
const hasDraft = await draftRow.count() > 0;
|
||||||
|
|
||||||
|
if (hasDraft) {
|
||||||
|
// 點擊 "編輯" 按鈕
|
||||||
|
await draftRow.locator('button[title="編輯"], a:has-text("編輯")').first().click();
|
||||||
|
} else {
|
||||||
|
throw new Error('測試環境中找不到單號為 TRF-E2E-FINAL 的調撥單。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 驗證已進入詳情頁 (標題包含調撥單單號)
|
||||||
|
await expect(page.getByRole('heading', { name: /調撥單: TRF-/ })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 4. 開啟匯入對話框
|
||||||
|
const importBtn = page.getByRole('button', { name: /匯入 Excel|匯入/ });
|
||||||
|
await expect(importBtn).toBeVisible();
|
||||||
|
await importBtn.click();
|
||||||
|
|
||||||
|
await expect(page.getByText('匯入調撥明細')).toBeVisible();
|
||||||
|
|
||||||
|
// 5. 準備測試檔案 (CSV 格式)
|
||||||
|
const csvPath = path.join('/tmp', 'transfer_import_test.csv');
|
||||||
|
// 欄位名稱必須與後端匹配,商品代碼使用 P2 (紅糖)
|
||||||
|
const csvContent = "商品代碼,數量,批號,備註\nP2,10,BATCH001,E2E Test Import\n";
|
||||||
|
fs.writeFileSync(csvPath, csvContent);
|
||||||
|
|
||||||
|
// 6. 執行上傳
|
||||||
|
await page.setInputFiles('input[type="file"]', csvPath);
|
||||||
|
|
||||||
|
// 7. 點擊開始匯入
|
||||||
|
await page.getByRole('button', { name: '開始匯入' }).click();
|
||||||
|
|
||||||
|
// 8. 等待頁面更新 (Inertia reload)
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// 9. 驗證詳情頁表格是否出現匯入的資料
|
||||||
|
// 注意:「E2E Test Import」是 input 的 value,不是靜態文字,hasText 無法匹配 input value
|
||||||
|
// 因此先找包含 P2 文字的行(P2 是靜態 text),再驗證備註 input 的值
|
||||||
|
const p2Row = page.locator('table tbody tr').filter({ hasText: 'P2' }).first();
|
||||||
|
await expect(p2Row).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 驗證備註欄位的 input value 包含測試標記
|
||||||
|
// 快照中備註欄位的 role 是 textbox,placeholder 是 "備註..."
|
||||||
|
const remarkInput = p2Row.getByRole('textbox', { name: '備註...' });
|
||||||
|
await expect(remarkInput).toHaveValue('E2E Test Import');
|
||||||
|
|
||||||
|
// 截圖留存
|
||||||
|
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||||
|
await page.screenshot({ path: 'e2e/screenshots/inventory-transfer-import-success.png', fullPage: true });
|
||||||
|
|
||||||
|
// 清理臨時檔案
|
||||||
|
if (fs.existsSync(csvPath)) fs.unlinkSync(csvPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
127
e2e/procurement.spec.ts
Normal file
127
e2e/procurement.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 採購模組端到端測試
|
||||||
|
* 驗證「批量寫入」(多筆明細 bulk insert) 與「併發鎖定」(狀態變更 lockForUpdate)
|
||||||
|
*/
|
||||||
|
test.describe('採購管理 - 採購單建立', () => {
|
||||||
|
// 登入 + 導航 + 表單操作需要較長時間
|
||||||
|
test.use({ actionTimeout: 15000 });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能成功建立含多筆明細的採購單', async ({ page }) => {
|
||||||
|
// 整體測試逾時設定為 90 秒(含多次選單互動)
|
||||||
|
test.setTimeout(90000);
|
||||||
|
|
||||||
|
// 1. 前往採購單列表
|
||||||
|
await page.goto('/purchase-orders');
|
||||||
|
await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible();
|
||||||
|
|
||||||
|
// 2. 點擊「建立採購單」按鈕
|
||||||
|
await page.getByRole('button', { name: /建立採購單/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: '建立採購單' })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// 3. 選擇倉庫 (使用 SearchableSelect combobox)
|
||||||
|
const warehouseCombobox = page.locator('label:has-text("預計入庫倉庫")').locator('..').getByRole('combobox');
|
||||||
|
await warehouseCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '中央倉庫' }).click();
|
||||||
|
|
||||||
|
// 4. 選擇供應商
|
||||||
|
const supplierCombobox = page.locator('label:has-text("供應商")').locator('..').getByRole('combobox');
|
||||||
|
await supplierCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '台積電' }).click();
|
||||||
|
|
||||||
|
// 5. 填寫下單日期(應該已有預設值,但確保有值)
|
||||||
|
const orderDateInput = page.locator('label:has-text("下單日期")').locator('..').locator('input[type="date"]');
|
||||||
|
const currentDate = await orderDateInput.inputValue();
|
||||||
|
if (!currentDate) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
await orderDateInput.fill(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 填寫備註
|
||||||
|
await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 批量寫入驗證');
|
||||||
|
|
||||||
|
// 7. 新增第一個品項
|
||||||
|
await page.getByRole('button', { name: '新增一個品項' }).click();
|
||||||
|
|
||||||
|
// 選擇商品(第一行)
|
||||||
|
const firstRow = page.locator('table tbody tr').first();
|
||||||
|
const firstProductCombobox = firstRow.getByRole('combobox').first();
|
||||||
|
await firstProductCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '紅糖' }).click();
|
||||||
|
|
||||||
|
// 填寫數量
|
||||||
|
const firstQtyInput = firstRow.locator('input[type="number"]').first();
|
||||||
|
await firstQtyInput.clear();
|
||||||
|
await firstQtyInput.fill('5');
|
||||||
|
|
||||||
|
// 填寫小計(主要金額欄位)
|
||||||
|
const firstSubtotalInput = firstRow.locator('input[type="number"]').nth(1);
|
||||||
|
await firstSubtotalInput.fill('500');
|
||||||
|
|
||||||
|
// 8. 新增第二個品項(驗證批量寫入)
|
||||||
|
await page.getByRole('button', { name: '新增一個品項' }).click();
|
||||||
|
|
||||||
|
const secondRow = page.locator('table tbody tr').nth(1);
|
||||||
|
const secondProductCombobox = secondRow.getByRole('combobox').first();
|
||||||
|
await secondProductCombobox.click();
|
||||||
|
await page.getByRole('option', { name: '粗吸管' }).click();
|
||||||
|
|
||||||
|
const secondQtyInput = secondRow.locator('input[type="number"]').first();
|
||||||
|
await secondQtyInput.clear();
|
||||||
|
await secondQtyInput.fill('10');
|
||||||
|
|
||||||
|
const secondSubtotalInput = secondRow.locator('input[type="number"]').nth(1);
|
||||||
|
await secondSubtotalInput.fill('200');
|
||||||
|
|
||||||
|
// 9. 點擊「確認發布採購單」
|
||||||
|
await page.getByRole('button', { name: '確認發布採購單' }).click();
|
||||||
|
|
||||||
|
// 10. 驗證結果 — 應跳轉回列表頁或顯示詳情頁
|
||||||
|
// Inertia.js 的 onSuccess 會觸發頁面導航
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ }))
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 11. 截圖留存
|
||||||
|
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||||
|
await page.screenshot({ path: 'e2e/screenshots/procurement-po-create-success.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能成功編輯採購單', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
// 1. 前往採購單列表
|
||||||
|
await page.goto('/purchase-orders');
|
||||||
|
await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible();
|
||||||
|
|
||||||
|
// 2. 找到並點擊第一個可編輯的採購單 (草稿或待審核狀態)
|
||||||
|
const editLink = page.locator('button[title="編輯"], a[title="編輯"]').first();
|
||||||
|
await expect(editLink).toBeVisible({ timeout: 10000 });
|
||||||
|
await editLink.click();
|
||||||
|
|
||||||
|
// 3. 驗證已進入編輯頁
|
||||||
|
await expect(page.getByRole('heading', { name: '編輯採購單' })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 4. 修改備註
|
||||||
|
await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 已被編輯過');
|
||||||
|
|
||||||
|
// 5. 點擊「更新採購單」
|
||||||
|
await page.getByRole('button', { name: '更新採購單' }).click();
|
||||||
|
|
||||||
|
// 6. 驗證結果 — 返回列表或詳情頁
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ }))
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 7. 截圖留存
|
||||||
|
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||||
|
await page.screenshot({ path: 'e2e/screenshots/procurement-po-edit-success.png', fullPage: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
22
e2e/production.spec.ts
Normal file
22
e2e/production.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('生產管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入配方管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/recipes');
|
||||||
|
await expect(page.getByRole('heading', { name: /配方管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入生產單管理頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/production-orders');
|
||||||
|
await expect(page.getByRole('heading', { name: /生產工單/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /建立生產單/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/products.spec.ts
Normal file
15
e2e/products.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('商品管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入商品列表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/products');
|
||||||
|
await expect(page.getByRole('heading', { name: /商品資料管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/sales.spec.ts
Normal file
15
e2e/sales.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('銷售管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip('應能進入銷貨匯入頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/sales/imports');
|
||||||
|
await expect(page.getByRole('heading', { name: /功能製作中/ })).toBeVisible();
|
||||||
|
// await expect(page.locator('table')).toBeVisible();
|
||||||
|
// await expect(page.getByRole('button', { name: /匯入/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/vendors.spec.ts
Normal file
15
e2e/vendors.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('供應商管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入供應商列表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/vendors');
|
||||||
|
await expect(page.getByRole('heading', { name: /廠商資料管理/ })).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
14
e2e/warehouses.spec.ts
Normal file
14
e2e/warehouses.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('倉庫管理模組', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('應能進入倉庫列表頁面並顯示主要元素', async ({ page }) => {
|
||||||
|
await page.goto('/warehouses');
|
||||||
|
await expect(page.getByRole('heading', { name: /倉庫管理/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /新增倉庫/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
13888
package-lock.json
generated
13888
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 生產工單完工入庫 - 選擇倉庫彈窗
|
* 生產工單完工入庫 - 選擇倉庫彈窗
|
||||||
|
* 含產出確認與耗損記錄功能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button";
|
|||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react";
|
import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react";
|
||||||
|
import { formatQuantity } from "@/lib/utils";
|
||||||
|
|
||||||
interface Warehouse {
|
interface Warehouse {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps {
|
|||||||
warehouseId: number;
|
warehouseId: number;
|
||||||
batchNumber: string;
|
batchNumber: string;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
|
actualOutputQuantity: number;
|
||||||
|
lossReason: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
warehouses: Warehouse[];
|
warehouses: Warehouse[];
|
||||||
processing?: boolean;
|
processing?: boolean;
|
||||||
// 新增商品資訊以利產生批號
|
// 商品資訊用於產生批號
|
||||||
productCode?: string;
|
productCode?: string;
|
||||||
productId?: number;
|
productId?: number;
|
||||||
|
// 預計產量(用於耗損計算)
|
||||||
|
outputQuantity: number;
|
||||||
|
// 成品單位名稱
|
||||||
|
unitName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WarehouseSelectionModal({
|
export default function WarehouseSelectionModal({
|
||||||
@@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({
|
|||||||
processing = false,
|
processing = false,
|
||||||
productCode,
|
productCode,
|
||||||
productId,
|
productId,
|
||||||
|
outputQuantity,
|
||||||
|
unitName = '',
|
||||||
}: WarehouseSelectionModalProps) {
|
}: WarehouseSelectionModalProps) {
|
||||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||||
const [batchNumber, setBatchNumber] = React.useState<string>("");
|
const [batchNumber, setBatchNumber] = React.useState<string>("");
|
||||||
const [expiryDate, setExpiryDate] = React.useState<string>("");
|
const [expiryDate, setExpiryDate] = React.useState<string>("");
|
||||||
|
const [actualOutputQuantity, setActualOutputQuantity] = React.useState<string>("");
|
||||||
|
const [lossReason, setLossReason] = React.useState<string>("");
|
||||||
|
|
||||||
|
// 當開啟時,初始化實際產出數量為預計產量
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setActualOutputQuantity(String(outputQuantity));
|
||||||
|
setLossReason("");
|
||||||
|
}
|
||||||
|
}, [isOpen, outputQuantity]);
|
||||||
|
|
||||||
// 當開啟時,嘗試產生成品批號 (若有資訊)
|
// 當開啟時,嘗試產生成品批號 (若有資訊)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({
|
|||||||
}
|
}
|
||||||
}, [isOpen, productCode, productId]);
|
}, [isOpen, productCode, productId]);
|
||||||
|
|
||||||
|
// 計算耗損數量
|
||||||
|
const actualQty = parseFloat(actualOutputQuantity) || 0;
|
||||||
|
const lossQuantity = outputQuantity - actualQty;
|
||||||
|
const hasLoss = lossQuantity > 0;
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (selectedId && batchNumber) {
|
if (selectedId && batchNumber && actualQty > 0) {
|
||||||
onConfirm({
|
onConfirm({
|
||||||
warehouseId: selectedId,
|
warehouseId: selectedId,
|
||||||
batchNumber,
|
batchNumber,
|
||||||
expiryDate
|
expiryDate,
|
||||||
|
actualOutputQuantity: actualQty,
|
||||||
|
lossReason: hasLoss ? lossReason : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 驗證:實際產出不可大於預計產量,也不可小於等於 0
|
||||||
|
const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-primary-main">
|
<DialogTitle className="flex items-center gap-2 text-primary-main">
|
||||||
<WarehouseIcon className="h-5 w-5" />
|
<WarehouseIcon className="h-5 w-5" />
|
||||||
選擇完工入庫倉庫
|
完工入庫確認
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-6 space-y-6">
|
<div className="py-6 space-y-6">
|
||||||
|
{/* 倉庫選擇 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||||
<WarehouseIcon className="h-3 w-3" />
|
<WarehouseIcon className="h-3 w-3" />
|
||||||
@@ -96,6 +127,7 @@ export default function WarehouseSelectionModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 成品批號 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||||
<Tag className="h-3 w-3" />
|
<Tag className="h-3 w-3" />
|
||||||
@@ -109,6 +141,7 @@ export default function WarehouseSelectionModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 成品效期 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||||
<CalendarIcon className="h-3 w-3" />
|
<CalendarIcon className="h-3 w-3" />
|
||||||
@@ -121,6 +154,64 @@ export default function WarehouseSelectionModal({
|
|||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔線 - 產出確認區 */}
|
||||||
|
<div className="border-t border-grey-4 pt-4">
|
||||||
|
<p className="text-xs font-bold text-grey-2 uppercase tracking-wider mb-4">產出確認</p>
|
||||||
|
|
||||||
|
{/* 預計產量(唯讀) */}
|
||||||
|
<div className="flex items-center justify-between mb-3 px-3 py-2 bg-grey-5 rounded-lg border border-grey-4">
|
||||||
|
<span className="text-sm text-grey-2">預計產量</span>
|
||||||
|
<span className="font-bold text-grey-0">
|
||||||
|
{formatQuantity(outputQuantity)} {unitName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 實際產出數量 */}
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">
|
||||||
|
實際產出數量 *
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
max={outputQuantity}
|
||||||
|
value={actualOutputQuantity}
|
||||||
|
onChange={(e) => setActualOutputQuantity(e.target.value)}
|
||||||
|
className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`}
|
||||||
|
/>
|
||||||
|
{unitName && <span className="text-sm text-grey-2 whitespace-nowrap">{unitName}</span>}
|
||||||
|
</div>
|
||||||
|
{actualQty > outputQuantity && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">實際產出不可超過預計產量</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 耗損顯示 */}
|
||||||
|
{hasLoss && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 space-y-2 animate-in fade-in duration-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-bold text-orange-700">
|
||||||
|
耗損數量:{formatQuantity(lossQuantity)} {unitName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-orange-600">
|
||||||
|
耗損原因 (選填)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={lossReason}
|
||||||
|
onChange={(e) => setLossReason(e.target.value)}
|
||||||
|
placeholder="例如:製作過程損耗、品質不合格..."
|
||||||
|
className="h-9 border-orange-200 focus:ring-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -134,7 +225,7 @@ export default function WarehouseSelectionModal({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!selectedId || !batchNumber || processing}
|
disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
|
||||||
className="gap-2 button-filled-primary"
|
className="gap-2 button-filled-primary"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface SearchableSelectProps {
|
|||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
/** 是否可清除選取 */
|
/** 是否可清除選取 */
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
|
/** 是否為無效狀態(顯示紅色邊框) */
|
||||||
|
"aria-invalid"?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchableSelect({
|
export function SearchableSelect({
|
||||||
@@ -52,6 +54,7 @@ export function SearchableSelect({
|
|||||||
searchThreshold = 10,
|
searchThreshold = 10,
|
||||||
showSearch,
|
showSearch,
|
||||||
isClearable = false,
|
isClearable = false,
|
||||||
|
"aria-invalid": ariaInvalid,
|
||||||
}: SearchableSelectProps) {
|
}: SearchableSelectProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -79,12 +82,15 @@ export function SearchableSelect({
|
|||||||
!selectedOption && "text-grey-3",
|
!selectedOption && "text-grey-3",
|
||||||
// Focus state - primary border with ring
|
// Focus state - primary border with ring
|
||||||
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
|
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
|
||||||
|
// Error state
|
||||||
|
ariaInvalid && "border-destructive ring-destructive/20",
|
||||||
// Disabled state
|
// Disabled state
|
||||||
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
// Height
|
// Height
|
||||||
"h-9",
|
"h-9",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-invalid={ariaInvalid}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedOption ? selectedOption.label : placeholder}
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
|
|||||||
@@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
const [recipes, setRecipes] = useState<any[]>([]);
|
const [recipes, setRecipes] = useState<any[]>([]);
|
||||||
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
||||||
|
|
||||||
const { data, setData, processing, errors } = useForm({
|
// 提交表單
|
||||||
|
const { data, setData, processing, errors, setError, clearErrors } = useForm({
|
||||||
product_id: "",
|
product_id: "",
|
||||||
warehouse_id: "",
|
warehouse_id: "",
|
||||||
output_quantity: "",
|
output_quantity: "",
|
||||||
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
|
|
||||||
remark: "",
|
remark: "",
|
||||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||||
});
|
});
|
||||||
@@ -108,7 +108,6 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
// 獲取特定商品在各倉庫的庫存分佈
|
// 獲取特定商品在各倉庫的庫存分佈
|
||||||
const fetchProductInventories = async (productId: string) => {
|
const fetchProductInventories = async (productId: string) => {
|
||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
|
|
||||||
if (loadingProducts[productId]) return;
|
if (loadingProducts[productId]) return;
|
||||||
|
|
||||||
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||||
@@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
item.unit_id = "";
|
item.unit_id = "";
|
||||||
item.ui_input_quantity = "";
|
item.ui_input_quantity = "";
|
||||||
item.ui_selected_unit = "base";
|
item.ui_selected_unit = "base";
|
||||||
// 清除 cache 資訊
|
|
||||||
delete item.ui_product_name;
|
delete item.ui_product_name;
|
||||||
delete item.ui_batch_number;
|
delete item.ui_batch_number;
|
||||||
delete item.ui_available_qty;
|
delete item.ui_available_qty;
|
||||||
@@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
|
|
||||||
if (field === 'ui_warehouse_id') {
|
if (field === 'ui_warehouse_id') {
|
||||||
item.inventory_id = "";
|
item.inventory_id = "";
|
||||||
// 不重置數量
|
|
||||||
// item.quantity_used = "";
|
|
||||||
// item.ui_input_quantity = "";
|
|
||||||
// item.ui_selected_unit = "base";
|
|
||||||
|
|
||||||
// 清除某些 cache
|
|
||||||
delete item.ui_batch_number;
|
delete item.ui_batch_number;
|
||||||
delete item.ui_available_qty;
|
delete item.ui_available_qty;
|
||||||
delete item.ui_expiry_date;
|
delete item.ui_expiry_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
|
|
||||||
if (field === 'inventory_id' && value) {
|
if (field === 'inventory_id' && value) {
|
||||||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||||
const inv = currentOptions.find(i => String(i.id) === value);
|
const inv = currentOptions.find(i => String(i.id) === value);
|
||||||
@@ -203,45 +193,31 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
item.ui_batch_number = inv.batch_number;
|
item.ui_batch_number = inv.batch_number;
|
||||||
item.ui_available_qty = inv.quantity;
|
item.ui_available_qty = inv.quantity;
|
||||||
item.ui_expiry_date = inv.expiry_date || '';
|
item.ui_expiry_date = inv.expiry_date || '';
|
||||||
|
|
||||||
// 單位與轉換率
|
|
||||||
item.ui_base_unit_name = inv.unit_name || '';
|
item.ui_base_unit_name = inv.unit_name || '';
|
||||||
item.ui_base_unit_id = inv.base_unit_id;
|
item.ui_base_unit_id = inv.base_unit_id;
|
||||||
item.ui_large_unit_id = inv.large_unit_id;
|
item.ui_large_unit_id = inv.large_unit_id;
|
||||||
item.ui_purchase_unit_id = inv.purchase_unit_id;
|
item.ui_purchase_unit_id = inv.purchase_unit_id;
|
||||||
item.ui_conversion_rate = inv.conversion_rate || 1;
|
item.ui_conversion_rate = inv.conversion_rate || 1;
|
||||||
item.ui_unit_cost = inv.unit_cost || 0;
|
item.ui_unit_cost = inv.unit_cost || 0;
|
||||||
|
|
||||||
// 預設單位
|
|
||||||
item.ui_selected_unit = 'base';
|
item.ui_selected_unit = 'base';
|
||||||
item.unit_id = String(inv.base_unit_id || '');
|
item.unit_id = String(inv.base_unit_id || '');
|
||||||
|
|
||||||
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
|
|
||||||
if (!item.ui_input_quantity) {
|
if (!item.ui_input_quantity) {
|
||||||
item.ui_input_quantity = formatQuantity(inv.quantity);
|
item.ui_input_quantity = formatQuantity(inv.quantity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 計算最終數量 (Base Quantity)
|
|
||||||
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
||||||
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||||||
const rate = item.ui_conversion_rate || 1;
|
const rate = item.ui_conversion_rate || 1;
|
||||||
|
item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty);
|
||||||
if (item.ui_selected_unit === 'large') {
|
item.unit_id = String(item.ui_base_unit_id || '');
|
||||||
item.quantity_used = String(inputQty * rate);
|
|
||||||
item.unit_id = String(item.ui_base_unit_id || '');
|
|
||||||
} else {
|
|
||||||
item.quantity_used = String(inputQty);
|
|
||||||
item.unit_id = String(item.ui_base_unit_id || '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updated[index] = item;
|
updated[index] = item;
|
||||||
setBomItems(updated);
|
setBomItems(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步 BOM items 到表單 data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData('items', bomItems.map(item => ({
|
setData('items', bomItems.map(item => ({
|
||||||
inventory_id: Number(item.inventory_id),
|
inventory_id: Number(item.inventory_id),
|
||||||
@@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
})));
|
})));
|
||||||
}, [bomItems]);
|
}, [bomItems]);
|
||||||
|
|
||||||
// 應用配方到表單 (獨立函式)
|
|
||||||
const applyRecipe = (recipe: any) => {
|
const applyRecipe = (recipe: any) => {
|
||||||
if (!recipe || !recipe.items) return;
|
if (!recipe || !recipe.items) return;
|
||||||
|
|
||||||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||||||
// 自動帶入配方標準產量
|
|
||||||
setData('output_quantity', formatQuantity(yieldQty));
|
setData('output_quantity', formatQuantity(yieldQty));
|
||||||
|
|
||||||
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
||||||
const baseQty = parseFloat(item.quantity || "0");
|
if (item.product_id) fetchProductInventories(String(item.product_id));
|
||||||
const calculatedQty = baseQty; // 保持精度
|
|
||||||
|
|
||||||
// 若有配方商品,預先載入庫存分佈
|
|
||||||
if (item.product_id) {
|
|
||||||
fetchProductInventories(String(item.product_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inventory_id: "",
|
inventory_id: "",
|
||||||
quantity_used: String(calculatedQty),
|
quantity_used: String(item.quantity || "0"),
|
||||||
unit_id: String(item.unit_id),
|
unit_id: String(item.unit_id),
|
||||||
ui_warehouse_id: "",
|
ui_warehouse_id: "",
|
||||||
ui_product_id: String(item.product_id),
|
ui_product_id: String(item.product_id),
|
||||||
ui_product_name: item.product_name,
|
ui_product_name: item.product_name,
|
||||||
ui_batch_number: "",
|
ui_batch_number: "",
|
||||||
ui_available_qty: 0,
|
ui_available_qty: 0,
|
||||||
ui_input_quantity: formatQuantity(calculatedQty),
|
ui_input_quantity: formatQuantity(item.quantity || "0"),
|
||||||
ui_selected_unit: 'base',
|
ui_selected_unit: 'base',
|
||||||
ui_base_unit_name: item.unit_name,
|
ui_base_unit_name: item.unit_name,
|
||||||
ui_base_unit_id: item.unit_id,
|
ui_base_unit_id: item.unit_id,
|
||||||
@@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
setBomItems(newBomItems);
|
setBomItems(newBomItems);
|
||||||
|
toast.success(`已自動載入配方: ${recipe.name}`);
|
||||||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
|
||||||
description: `標準產量: ${formatQuantity(yieldQty)} 份`
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 當手動切換配方時
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedRecipeId) return;
|
if (!selectedRecipeId) return;
|
||||||
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
||||||
if (targetRecipe) {
|
if (targetRecipe) applyRecipe(targetRecipe);
|
||||||
applyRecipe(targetRecipe);
|
|
||||||
}
|
|
||||||
}, [selectedRecipeId]);
|
}, [selectedRecipeId]);
|
||||||
|
|
||||||
// 自動產生成品批號與載入配方
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data.product_id) return;
|
if (!data.product_id) return;
|
||||||
|
|
||||||
// 2. 自動載入配方列表
|
|
||||||
const fetchRecipes = async () => {
|
const fetchRecipes = async () => {
|
||||||
try {
|
try {
|
||||||
// 改為抓取所有配方
|
|
||||||
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
||||||
const recipesData = await res.json();
|
const recipesData = await res.json();
|
||||||
|
|
||||||
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
||||||
setRecipes(recipesData);
|
setRecipes(recipesData);
|
||||||
// 預設選取最新的 (第一個)
|
setSelectedRecipeId(String(recipesData[0].id));
|
||||||
const latest = recipesData[0];
|
|
||||||
setSelectedRecipeId(String(latest.id));
|
|
||||||
} else {
|
} else {
|
||||||
// 若無配方
|
|
||||||
setRecipes([]);
|
setRecipes([]);
|
||||||
setSelectedRecipeId("");
|
setSelectedRecipeId("");
|
||||||
setBomItems([]); // 清空 BOM
|
setBomItems([]);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch recipes", e);
|
|
||||||
setRecipes([]);
|
setRecipes([]);
|
||||||
setBomItems([]);
|
setBomItems([]);
|
||||||
}
|
}
|
||||||
@@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
fetchRecipes();
|
fetchRecipes();
|
||||||
}, [data.product_id]);
|
}, [data.product_id]);
|
||||||
|
|
||||||
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
|
// 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bomItems.length > 0 && data.output_quantity) {
|
const errorKeys = Object.keys(errors);
|
||||||
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
|
if (errorKeys.length > 0) {
|
||||||
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
|
// 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
|
||||||
// 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
|
setTimeout(() => {
|
||||||
|
const firstInvalid = document.querySelector('[aria-invalid="true"]');
|
||||||
|
if (firstInvalid instanceof HTMLElement) {
|
||||||
|
firstInvalid.focus();
|
||||||
|
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [data.output_quantity]);
|
}, [errors]);
|
||||||
|
|
||||||
// 提交表單
|
const submit = (status: 'draft') => {
|
||||||
const submit = (status: 'draft' | 'completed') => {
|
clearErrors();
|
||||||
// 驗證(簡單前端驗證,完整驗證在後端)
|
let hasError = false;
|
||||||
if (status === 'completed') {
|
|
||||||
const missingFields = [];
|
|
||||||
if (!data.product_id) missingFields.push('成品商品');
|
|
||||||
if (!data.output_quantity) missingFields.push('生產數量');
|
|
||||||
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
|
|
||||||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
// 草稿建立時也要求必填生產數量與預計入庫倉庫
|
||||||
toast.error("請填寫必要欄位", {
|
if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
|
||||||
description: `缺漏:${missingFields.join('、')}`
|
if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
|
||||||
});
|
if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
|
||||||
return;
|
if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; }
|
||||||
|
|
||||||
|
// 驗證 BOM 明細
|
||||||
|
bomItems.forEach((item, index) => {
|
||||||
|
if (!item.ui_product_id) {
|
||||||
|
setError(`items.${index}.ui_product_id` as any, '請選擇商品');
|
||||||
|
hasError = true;
|
||||||
|
} else {
|
||||||
|
if (!item.inventory_id) {
|
||||||
|
setError(`items.${index}.inventory_id` as any, '請選擇批號');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) {
|
||||||
|
setError(`items.${index}.quantity_used` as any, '請輸入數量');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
toast.error("建立失敗,請檢查標單內紅框欄位");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 轉換 BOM items 格式
|
const formattedItems = bomItems.map(item => ({
|
||||||
const formattedItems = bomItems
|
inventory_id: parseInt(item.inventory_id),
|
||||||
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
|
quantity_used: parseFloat(item.quantity_used),
|
||||||
.map(item => ({
|
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
||||||
inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null,
|
}));
|
||||||
quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
|
|
||||||
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 使用 router.post 提交完整資料
|
|
||||||
router.post(route('production-orders.store'), {
|
router.post(route('production-orders.store'), {
|
||||||
...data,
|
...data,
|
||||||
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
|
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
|
||||||
items: formattedItems,
|
items: formattedItems,
|
||||||
status: status,
|
status: status,
|
||||||
}, {
|
}, {
|
||||||
onError: (errors) => {
|
onError: () => {
|
||||||
const errorCount = Object.keys(errors).length;
|
toast.error("建立失敗,請檢查表單");
|
||||||
toast.error("建立失敗,請檢查表單", {
|
|
||||||
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit('completed');
|
submit('draft');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBomItemUnitCost = (item: BomItem) => {
|
const getBomItemUnitCost = (item: BomItem) => {
|
||||||
@@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
<Factory className="h-6 w-6 text-primary-main" />
|
<Factory className="h-6 w-6 text-primary-main" />
|
||||||
建立生產工單
|
建立生產工單
|
||||||
</h1>
|
</h1>
|
||||||
@@ -431,14 +394,18 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
建立新的生產排程,選擇原物料並記錄產出
|
建立新的生產排程,選擇原物料並記錄產出
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-3">
|
||||||
onClick={() => submit('draft')}
|
<Button
|
||||||
disabled={processing}
|
type="button"
|
||||||
className="gap-2 button-filled-primary"
|
variant="default"
|
||||||
>
|
onClick={() => submit('draft')}
|
||||||
<Save className="h-4 w-4" />
|
disabled={processing}
|
||||||
儲存工單 (草稿)
|
className="button-filled-primary gap-2"
|
||||||
</Button>
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
儲存工單 (草稿)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
}))}
|
}))}
|
||||||
placeholder="選擇成品"
|
placeholder="選擇成品"
|
||||||
className="w-full h-9"
|
className="w-full h-9"
|
||||||
|
aria-invalid={!!errors.product_id}
|
||||||
/>
|
/>
|
||||||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
||||||
|
|
||||||
@@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||||
placeholder="例如: 50"
|
placeholder="例如: 50"
|
||||||
className="h-9 font-mono"
|
className="h-9 font-mono"
|
||||||
|
aria-invalid={!!errors.output_quantity}
|
||||||
/>
|
/>
|
||||||
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
}))}
|
}))}
|
||||||
placeholder="選擇倉庫"
|
placeholder="選擇倉庫"
|
||||||
className="w-full h-9"
|
className="w-full h-9"
|
||||||
|
aria-invalid={!!errors.warehouse_id}
|
||||||
/>
|
/>
|
||||||
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
options={productOptions}
|
options={productOptions}
|
||||||
placeholder="選擇商品"
|
placeholder="選擇商品"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!item.ui_warehouse_id}
|
disabled={!item.ui_warehouse_id}
|
||||||
|
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
|
||||||
/>
|
/>
|
||||||
{item.inventory_id && (() => {
|
{item.inventory_id && (() => {
|
||||||
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||||
@@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) {
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
className="h-9 text-right"
|
className="h-9 text-right"
|
||||||
disabled={!item.inventory_id}
|
disabled={!item.inventory_id}
|
||||||
|
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ interface ProductionOrder {
|
|||||||
output_batch_number: string;
|
output_batch_number: string;
|
||||||
output_box_count: string | null;
|
output_box_count: string | null;
|
||||||
output_quantity: number;
|
output_quantity: number;
|
||||||
|
actual_output_quantity: number | null;
|
||||||
|
loss_reason: string | null;
|
||||||
production_date: string;
|
production_date: string;
|
||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
status: ProductionOrderStatus;
|
status: ProductionOrderStatus;
|
||||||
@@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
warehouseId?: number;
|
warehouseId?: number;
|
||||||
batchNumber?: string;
|
batchNumber?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
|
actualOutputQuantity?: number;
|
||||||
|
lossReason?: string;
|
||||||
}) => {
|
}) => {
|
||||||
router.patch(route('production-orders.update-status', productionOrder.id), {
|
router.patch(route('production-orders.update-status', productionOrder.id), {
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
warehouse_id: extraData?.warehouseId,
|
warehouse_id: extraData?.warehouseId,
|
||||||
output_batch_number: extraData?.batchNumber,
|
output_batch_number: extraData?.batchNumber,
|
||||||
expiry_date: extraData?.expiryDate,
|
expiry_date: extraData?.expiryDate,
|
||||||
|
actual_output_quantity: extraData?.actualOutputQuantity,
|
||||||
|
loss_reason: extraData?.lossReason,
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIsWarehouseModalOpen(false);
|
setIsWarehouseModalOpen(false);
|
||||||
@@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
processing={processing}
|
processing={processing}
|
||||||
productCode={productionOrder.product?.code}
|
productCode={productionOrder.product?.code}
|
||||||
productId={productionOrder.product?.id}
|
productId={productionOrder.product?.id}
|
||||||
|
outputQuantity={Number(productionOrder.output_quantity)}
|
||||||
|
unitName={productionOrder.product?.base_unit?.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||||
@@ -276,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計/實際產量</p>
|
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計產量</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<p className="font-bold text-grey-0 text-xl">
|
<p className="font-bold text-grey-0 text-xl">
|
||||||
{formatQuantity(productionOrder.output_quantity)}
|
{formatQuantity(productionOrder.output_quantity)}
|
||||||
@@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 實際產量與耗損(僅完成狀態顯示) */}
|
||||||
|
{productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">實際產量</p>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<p className="font-bold text-grey-0 text-xl">
|
||||||
|
{formatQuantity(productionOrder.actual_output_quantity)}
|
||||||
|
</p>
|
||||||
|
{productionOrder.product?.base_unit?.name && (
|
||||||
|
<span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
|
||||||
|
)}
|
||||||
|
{Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold border border-orange-200">
|
||||||
|
耗損 {formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{productionOrder.loss_reason && (
|
||||||
|
<p className="text-xs text-orange-600 mt-1">原因:{productionOrder.loss_reason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">入庫倉庫</p>
|
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">入庫倉庫</p>
|
||||||
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
|
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
|||||||
|
|
||||||
| 參數名稱 | 類型 | 必填 | 說明 |
|
| 參數名稱 | 類型 | 必填 | 說明 |
|
||||||
| :--- | :--- | :---: | :--- |
|
| :--- | :--- | :---: | :--- |
|
||||||
| `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`) |
|
| `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`,測試可使用預設建立之 `api-test-01`) |
|
||||||
|
|
||||||
### Response
|
### Response
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"warehouse_code": "STORE-001",
|
"warehouse_code": "api-test-01",
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"external_pos_id": "POS-ITEM-001",
|
"external_pos_id": "POS-ITEM-001",
|
||||||
@@ -136,7 +136,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
|||||||
| 欄位名稱 | 型態 | 必填 | 說明 |
|
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||||
| :--- | :--- | :---: | :--- |
|
| :--- | :--- | :---: | :--- |
|
||||||
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
|
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
|
||||||
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`STORE-001`)。若找不到對應倉庫將直接拒絕請求 |
|
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`api-test-01` 測試倉)。若找不到對應倉庫將直接拒絕請求 |
|
||||||
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
|
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
|
||||||
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
|
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
|
||||||
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
|
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
|
||||||
@@ -153,7 +153,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"external_order_id": "ORD-20231026-0001",
|
"external_order_id": "ORD-20231026-0001",
|
||||||
"warehouse_code": "STORE-001",
|
"warehouse_code": "api-test-01",
|
||||||
"payment_method": "credit_card",
|
"payment_method": "credit_card",
|
||||||
"sold_at": "2023-10-26 14:30:00",
|
"sold_at": "2023-10-26 14:30:00",
|
||||||
"items": [
|
"items": [
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ class PosApiTest extends TestCase
|
|||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'external_order_id' => 'ORD-001',
|
'external_order_id' => 'ORD-001',
|
||||||
'warehouse_id' => $warehouseId,
|
'warehouse_code' => 'MAIN',
|
||||||
'sold_at' => now()->toIso8601String(),
|
'sold_at' => now()->toIso8601String(),
|
||||||
'items' => [
|
'items' => [
|
||||||
[
|
[
|
||||||
@@ -175,6 +175,9 @@ class PosApiTest extends TestCase
|
|||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
])->postJson('/api/v1/integration/orders', $payload);
|
])->postJson('/api/v1/integration/orders', $payload);
|
||||||
|
|
||||||
|
$response->assertStatus(201)
|
||||||
|
->assertJsonPath('message', 'Order synced and stock deducted successfully');
|
||||||
|
|
||||||
$response->assertStatus(201)
|
$response->assertStatus(201)
|
||||||
->assertJsonPath('message', 'Order synced and stock deducted successfully');
|
->assertJsonPath('message', 'Order synced and stock deducted successfully');
|
||||||
|
|
||||||
@@ -197,6 +200,14 @@ class PosApiTest extends TestCase
|
|||||||
'quantity' => 95,
|
'quantity' => 95,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$order = \App\Modules\Integration\Models\SalesOrder::where('external_order_id', 'ORD-001')->first();
|
||||||
|
$this->assertDatabaseHas('inventory_transactions', [
|
||||||
|
'reference_type' => \App\Modules\Integration\Models\SalesOrder::class,
|
||||||
|
'reference_id' => $order->id,
|
||||||
|
'quantity' => -5,
|
||||||
|
'type' => '出庫',
|
||||||
|
]);
|
||||||
|
|
||||||
tenancy()->end();
|
tenancy()->end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class InventoryTransferImportTest extends TestCase
|
|||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
protected $user;
|
protected $user;
|
||||||
|
protected $tenant;
|
||||||
protected $fromWarehouse;
|
protected $fromWarehouse;
|
||||||
protected $toWarehouse;
|
protected $toWarehouse;
|
||||||
protected $order;
|
protected $order;
|
||||||
@@ -25,6 +26,15 @@ class InventoryTransferImportTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
// Create a unique tenant for this test run
|
||||||
|
$tenantId = 'test_' . str_replace('.', '', microtime(true));
|
||||||
|
$this->tenant = \App\Modules\Core\Models\Tenant::create([
|
||||||
|
'id' => $tenantId,
|
||||||
|
]);
|
||||||
|
$this->tenant->domains()->create(['domain' => $tenantId . '.test']);
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
|
||||||
$this->user = User::create([
|
$this->user = User::create([
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'username' => 'testuser',
|
'username' => 'testuser',
|
||||||
@@ -52,10 +62,13 @@ class InventoryTransferImportTest extends TestCase
|
|||||||
'created_by' => $this->user->id,
|
'created_by' => $this->user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$category = \App\Modules\Inventory\Models\Category::create(['name' => 'General', 'code' => 'GEN']);
|
||||||
|
|
||||||
$this->product = Product::create([
|
$this->product = Product::create([
|
||||||
'code' => 'P001',
|
'code' => 'P001',
|
||||||
'name' => 'Test Product',
|
'name' => 'Test Product',
|
||||||
'status' => 'enabled',
|
'status' => 'enabled',
|
||||||
|
'category_id' => $category->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +93,9 @@ class InventoryTransferImportTest extends TestCase
|
|||||||
// 但如果我們在 Import 類別中直接讀取 $row['商品代碼'],我們得確定它真的在那裡。
|
// 但如果我們在 Import 類別中直接讀取 $row['商品代碼'],我們得確定它真的在那裡。
|
||||||
|
|
||||||
$rows = collect([
|
$rows = collect([
|
||||||
collect(['商品代碼' => 'P001', '批號' => 'BATCH001', '數量' => '10', '備註' => 'Imported Via Test']),
|
collect(['商品代碼', '批號', '數量', '備註']),
|
||||||
collect(['商品代碼' => 'P001', '批號' => '', '數量' => '5', '備註' => 'Batch should be NO-BATCH']),
|
collect(['P001', 'BATCH001', '10', 'Imported Via Test']),
|
||||||
|
collect(['P001', '', '5', 'Batch should be NO-BATCH']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$import->collection($rows);
|
$import->collection($rows);
|
||||||
|
|||||||
Reference in New Issue
Block a user