From 60f5f00a9eaf03adade0396ccd5190ac485545e9 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 19 Mar 2026 15:00:33 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E9=8A=B7=E5=94=AE=E8=A8=82=E5=96=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=9A=E8=A3=9C=E9=BD=8A=E6=AC=84=E4=BD=8D?= =?UTF-8?q?=E3=80=81=E5=8D=B3=E6=99=82=E6=90=9C=E5=B0=8B=E3=80=81=E7=AF=A9?= =?UTF-8?q?=E9=81=B8=E8=88=87=E4=BE=86=E6=BA=90=E8=87=AA=E5=8B=95=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Integration/Actions/SyncOrderAction.php | 34 +++--- .../Controllers/SalesOrderController.php | 12 +- app/Modules/Integration/Models/SalesOrder.php | 3 + .../Integration/Requests/SyncOrderRequest.php | 3 + .../Inventory/Services/ProductService.php | 19 +-- ...add_total_qty_and_name_to_sales_orders.php | 29 +++++ ...e(['warehouses' => $w, 'products' => $p]); | 21 ++++ .../Pages/Integration/SalesOrders/Index.tsx | 114 ++++++++++++------ .../js/Pages/Integration/SalesOrders/Show.tsx | 10 ++ resources/markdown/manual/api-integration.md | 12 +- tests/Feature/Integration/PosApiTest.php | 75 +++++++++++- 11 files changed, 269 insertions(+), 63 deletions(-) create mode 100644 database/migrations/tenant/2026_03_19_142109_add_total_qty_and_name_to_sales_orders.php create mode 100644 on_encode(['warehouses' => $w, 'products' => $p]); diff --git a/app/Modules/Integration/Actions/SyncOrderAction.php b/app/Modules/Integration/Actions/SyncOrderAction.php index ae2fe7d..3041c6e 100644 --- a/app/Modules/Integration/Actions/SyncOrderAction.php +++ b/app/Modules/Integration/Actions/SyncOrderAction.php @@ -87,19 +87,7 @@ class SyncOrderAction // --- 執行寫入交易 --- $result = DB::transaction(function () use ($data, $items, $resolvedProducts) { - // 1. 建立訂單 - $order = SalesOrder::create([ - 'external_order_id' => $data['external_order_id'], - 'status' => 'completed', - 'payment_method' => $data['payment_method'] ?? 'cash', - 'total_amount' => 0, - 'sold_at' => $data['sold_at'] ?? now(), - 'raw_payload' => $data, - 'source' => $data['source'] ?? 'pos', - 'source_label' => $data['source_label'] ?? null, - ]); - - // 2. 查找倉庫 + // 1. 查找倉庫(提前至建立訂單前,以便判定來源) $warehouseCode = $data['warehouse_code']; $warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]); @@ -108,7 +96,25 @@ class SyncOrderAction 'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."] ]); } - $warehouseId = $warehouses->first()->id; + $warehouse = $warehouses->first(); + $warehouseId = $warehouse->id; + + // 2. 自動判定來源:若是販賣機倉庫則標記為 vending,其餘為 pos + $source = ($warehouse->type === \App\Enums\WarehouseType::VENDING) ? 'vending' : 'pos'; + + // 3. 建立訂單 + $order = SalesOrder::create([ + 'external_order_id' => $data['external_order_id'], + 'name' => $data['name'], + 'status' => 'completed', + 'payment_method' => $data['payment_method'] ?? 'cash', + 'total_amount' => $data['total_amount'], + 'total_qty' => $data['total_qty'], + 'sold_at' => $data['sold_at'] ?? now(), + 'raw_payload' => $data, + 'source' => $source, + 'source_label' => $data['source_label'] ?? null, + ]); $totalAmount = 0; diff --git a/app/Modules/Integration/Controllers/SalesOrderController.php b/app/Modules/Integration/Controllers/SalesOrderController.php index ced9ec7..0489426 100644 --- a/app/Modules/Integration/Controllers/SalesOrderController.php +++ b/app/Modules/Integration/Controllers/SalesOrderController.php @@ -18,7 +18,10 @@ class SalesOrderController extends Controller // 搜尋篩選 (外部訂單號) if ($request->filled('search')) { - $query->where('external_order_id', 'like', '%' . $request->search . '%'); + $query->where(function ($q) use ($request) { + $q->where('external_order_id', 'like', '%' . $request->search . '%') + ->orWhere('name', 'like', '%' . $request->search . '%'); + }); } // 來源篩選 @@ -26,6 +29,11 @@ class SalesOrderController extends Controller $query->where('source', $request->source); } + // 付款方式篩選 + if ($request->filled('payment_method')) { + $query->where('payment_method', $request->payment_method); + } + // 排序 $query->orderBy('sold_at', 'desc'); @@ -40,7 +48,7 @@ class SalesOrderController extends Controller return Inertia::render('Integration/SalesOrders/Index', [ 'orders' => $orders, - 'filters' => $request->only(['search', 'per_page', 'source']), + 'filters' => $request->only(['search', 'per_page', 'source', 'status', 'payment_method']), ]); } diff --git a/app/Modules/Integration/Models/SalesOrder.php b/app/Modules/Integration/Models/SalesOrder.php index 1a67e96..25e1b65 100644 --- a/app/Modules/Integration/Models/SalesOrder.php +++ b/app/Modules/Integration/Models/SalesOrder.php @@ -11,9 +11,11 @@ class SalesOrder extends Model protected $fillable = [ 'external_order_id', + 'name', 'status', 'payment_method', 'total_amount', + 'total_qty', 'sold_at', 'raw_payload', 'source', @@ -24,6 +26,7 @@ class SalesOrder extends Model 'sold_at' => 'datetime', 'raw_payload' => 'array', 'total_amount' => 'decimal:4', + 'total_qty' => 'decimal:4', ]; public function items(): HasMany diff --git a/app/Modules/Integration/Requests/SyncOrderRequest.php b/app/Modules/Integration/Requests/SyncOrderRequest.php index f694ce6..30ab09f 100644 --- a/app/Modules/Integration/Requests/SyncOrderRequest.php +++ b/app/Modules/Integration/Requests/SyncOrderRequest.php @@ -23,8 +23,11 @@ class SyncOrderRequest extends FormRequest { return [ 'external_order_id' => 'required|string', + 'name' => 'required|string|max:255', 'warehouse_code' => 'required|string', 'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other', + 'total_amount' => 'required|numeric|min:0', + 'total_qty' => 'required|numeric|min:0', 'sold_at' => 'nullable|date', 'items' => 'required|array|min:1', 'items.*.product_id' => 'required|integer', diff --git a/app/Modules/Inventory/Services/ProductService.php b/app/Modules/Inventory/Services/ProductService.php index f3f8888..c21316b 100644 --- a/app/Modules/Inventory/Services/ProductService.php +++ b/app/Modules/Inventory/Services/ProductService.php @@ -190,10 +190,10 @@ class ProductService implements ProductServiceInterface { $product = null; if (!empty($barcode)) { - $product = Product::query()->where('barcode', $barcode)->first(); + $product = Product::where('barcode', $barcode)->first(); } if (!$product && !empty($code)) { - $product = Product::query()->where('code', $code)->first(); + $product = Product::where('code', $code)->first(); } return $product; } @@ -207,7 +207,6 @@ class ProductService implements ProductServiceInterface */ public function searchProducts(array $filters, int $perPage = 50) { - /** @var \Illuminate\Database\Eloquent\Builder $query */ $query = Product::query() ->with(['category', 'baseUnit']) ->where('is_active', true); @@ -226,12 +225,16 @@ class ProductService implements ProductServiceInterface $query->where('external_pos_id', $filters['external_pos_id']); } - // 3. 分類過濾 + // 3. 分類過濾 (優先使用 ID,若傳入字串則按名稱) if (!empty($filters['category'])) { - $categoryName = $filters['category']; - $query->whereHas('category', function ($q) use ($categoryName) { - $q->where('name', $categoryName); - }); + $categoryVal = $filters['category']; + if (is_numeric($categoryVal)) { + $query->where('category_id', $categoryVal); + } else { + $query->whereHas('category', function ($q) use ($categoryVal) { + $q->where('name', $categoryVal); + }); + } } // 4. 增量同步 (Updated After) diff --git a/database/migrations/tenant/2026_03_19_142109_add_total_qty_and_name_to_sales_orders.php b/database/migrations/tenant/2026_03_19_142109_add_total_qty_and_name_to_sales_orders.php new file mode 100644 index 0000000..d8f3ba3 --- /dev/null +++ b/database/migrations/tenant/2026_03_19_142109_add_total_qty_and_name_to_sales_orders.php @@ -0,0 +1,29 @@ +decimal('total_qty', 12, 4)->default(0)->after('total_amount'); + $table->string('name')->after('external_order_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sales_orders', function (Blueprint $table) { + $table->dropColumn(['total_qty', 'name']); + }); + } +}; diff --git a/on_encode(['warehouses' => $w, 'products' => $p]); b/on_encode(['warehouses' => $w, 'products' => $p]); new file mode 100644 index 0000000..e4c14d9 --- /dev/null +++ b/on_encode(['warehouses' => $w, 'products' => $p]); @@ -0,0 +1,21 @@ + + SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS + + Commands marked with * may be preceded by a number, _N. + Notes in parentheses indicate the behavior if _N is given. + A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. + + h H Display this help. + q :q Q :Q ZZ Exit. + --------------------------------------------------------------------------- + + MMOOVVIINNGG + + e ^E j ^N CR * Forward one line (or _N lines). + y ^Y k ^K ^P * Backward one line (or _N lines). + f ^F ^V SPACE * Forward one window (or _N lines). + b ^B ESC-v * Backward one window (or _N lines). + z * Forward one window (and set window to _N). + w * Backward one window (and set window to _N). + ESC-SPACE * Forward one window, but don't stop at end-of-file. + d ^D * Forward one half-window (and set half-window to _N). diff --git a/resources/js/Pages/Integration/SalesOrders/Index.tsx b/resources/js/Pages/Integration/SalesOrders/Index.tsx index 9a168a0..5ca285f 100644 --- a/resources/js/Pages/Integration/SalesOrders/Index.tsx +++ b/resources/js/Pages/Integration/SalesOrders/Index.tsx @@ -1,10 +1,12 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; +import { debounce } from "lodash"; import { Head, Link, router } from "@inertiajs/react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Search, TrendingUp, Eye, + Trash2, } from "lucide-react"; import { Table, @@ -26,9 +28,11 @@ import { Can } from "@/Components/Permission/Can"; interface SalesOrder { id: number; external_order_id: string; + name: string | null; status: string; payment_method: string; total_amount: string; + total_qty: string; sold_at: string; created_at: string; source: string; @@ -54,6 +58,8 @@ interface Props { search?: string; per_page?: string; source?: string; + status?: string; + payment_method?: string; }; } @@ -65,6 +71,14 @@ const sourceOptions = [ { label: "手動匯入", value: "manual_import" }, ]; +const paymentMethodOptions = [ + { label: "全部付款方式", value: "" }, + { label: "現金", value: "cash" }, + { label: "信用卡", value: "credit_card" }, + { label: "Line Pay", value: "line_pay" }, + { label: "悠遊卡", value: "easycard" }, +]; + const getSourceLabel = (source: string): string => { switch (source) { case 'pos': return 'POS'; @@ -105,12 +119,32 @@ export default function SalesOrderIndex({ orders, filters }: Props) { const [search, setSearch] = useState(filters.search || ""); const [perPage, setPerPage] = useState(filters.per_page || "10"); - const handleSearch = () => { - router.get( - route("integration.sales-orders.index"), - { ...filters, search, page: 1 }, - { preserveState: true, replace: true, preserveScroll: true } - ); + const debouncedFilter = useCallback( + debounce((params: any) => { + router.get(route("integration.sales-orders.index"), params, { + preserveState: true, + replace: true, + preserveScroll: true, + }); + }, 300), + [] + ); + + const handleSearchChange = (term: string) => { + setSearch(term); + debouncedFilter({ + ...filters, + search: term, + page: 1, + }); + }; + + const handleFilterChange = (key: string, value: string) => { + debouncedFilter({ + ...filters, + [key]: value || undefined, + page: 1, + }); }; const handlePerPageChange = (value: string) => { @@ -153,38 +187,40 @@ export default function SalesOrderIndex({ orders, filters }: Props) { {/* 篩選列 */}
- - router.get( - route("integration.sales-orders.index"), - { ...filters, source: v || undefined, page: 1 }, - { preserveState: true, replace: true, preserveScroll: true } - ) - } - options={sourceOptions} - className="w-[160px] h-9" - showSearch={false} - placeholder="篩選來源" - /> -
+
+ setSearch(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - placeholder="搜尋外部訂單號 (External Order ID)..." - className="h-9" + onChange={(e) => handleSearchChange(e.target.value)} + placeholder="搜尋訂單名稱或外部訂單號 (External Order ID)..." + className="pl-10 pr-10 h-9" /> - + {search && ( + + )}
+ handleFilterChange("source", val)} + options={sourceOptions} + className="w-[140px] h-9" + showSearch={false} + placeholder="篩選來源" + /> + handleFilterChange("payment_method", val)} + options={paymentMethodOptions} + className="w-[160px] h-9" + showSearch={false} + placeholder="篩選付款方式" + />
@@ -195,9 +231,11 @@ export default function SalesOrderIndex({ orders, filters }: Props) { # 外部訂單號 + 名稱 來源 狀態 付款方式 + 總數量 總金額 銷售時間 操作 @@ -206,7 +244,7 @@ export default function SalesOrderIndex({ orders, filters }: Props) { {orders.data.length === 0 ? ( - + 無符合條件的資料 @@ -219,6 +257,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) { {order.external_order_id} + + {order.name || "—"} + {order.source_label || getSourceLabel(order.source)} @@ -233,6 +274,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) { {order.payment_method || "—"} + {formatNumber(parseFloat(order.total_qty))} + + ${formatNumber(parseFloat(order.total_amount))} diff --git a/resources/js/Pages/Integration/SalesOrders/Show.tsx b/resources/js/Pages/Integration/SalesOrders/Show.tsx index d5d6a1a..f5caa3d 100644 --- a/resources/js/Pages/Integration/SalesOrders/Show.tsx +++ b/resources/js/Pages/Integration/SalesOrders/Show.tsx @@ -28,7 +28,9 @@ interface SalesOrder { status: string; payment_method: string; total_amount: string; + total_qty: string; sold_at: string; + name: string | null; created_at: string; raw_payload: any; items: SalesOrderItem[]; @@ -103,6 +105,7 @@ export default function SalesOrderShow({ order }: Props) {

銷售時間: {formatDate(order.sold_at)} | + 名稱: {order.name || "—"} | 付款方式: {order.payment_method || "—"} | 訂單來源: {getSourceDisplay(order.source, order.source_label)} | 同步時間: {formatDate(order.created_at as any)} @@ -146,6 +149,13 @@ export default function SalesOrderShow({ order }: Props) {

+
+ 訂單總數量 + + {formatNumber(parseFloat(order.total_qty))} + +
+
訂單總金額 diff --git a/resources/markdown/manual/api-integration.md b/resources/markdown/manual/api-integration.md index 448235f..1170db6 100644 --- a/resources/markdown/manual/api-integration.md +++ b/resources/markdown/manual/api-integration.md @@ -231,8 +231,11 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS | 欄位名稱 | 型態 | 必填 | 說明 | | :--- | :--- | :---: | :--- | | `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) | +| `name` | String | **是** | 訂單名稱或客戶名稱 (最多 255 字元) | | `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`api-test-01` 測試倉)。若找不到對應倉庫將直接拒絕請求 | | `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` | +| `total_amount` | Number | **是** | 整筆訂單的總交易金額 (例如:500) | +| `total_qty` | Number | **是** | 整筆訂單的商品總數量 (例如:5) | | `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) | | `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 | @@ -250,10 +253,13 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS **請求範例:** ```json { - "external_order_id": "ORD-20240320-0001", - "warehouse_code": "api-test-01", + "external_order_id": "ORD-20231024-001", + "name": "陳小明-干貝醬訂購", + "warehouse_code": "STORE-01", "payment_method": "credit_card", - "sold_at": "2024-03-20 14:30:00", + "total_amount": 1050, + "total_qty": 3, + "sold_at": "2023-10-24 15:30:00", "items": [ { "product_id": 15, diff --git a/tests/Feature/Integration/PosApiTest.php b/tests/Feature/Integration/PosApiTest.php index 9eeb168..a0ac522 100644 --- a/tests/Feature/Integration/PosApiTest.php +++ b/tests/Feature/Integration/PosApiTest.php @@ -171,7 +171,11 @@ class PosApiTest extends TestCase $payload = [ 'external_order_id' => 'ORD-001', + 'name' => '測試訂單一號', 'warehouse_code' => 'MAIN', + 'payment_method' => 'cash', + 'total_amount' => 500, // 5 * 100 + 'total_qty' => 5, 'sold_at' => now()->toIso8601String(), 'items' => [ [ @@ -252,7 +256,11 @@ class PosApiTest extends TestCase // 1. Test deducting from SPECIFIC batch $payloadA = [ 'external_order_id' => 'ORD-BATCH-A', + 'name' => '測試訂單A', 'warehouse_code' => 'MAIN', + 'payment_method' => 'cash', + 'total_amount' => 300, // 3 * 100 + 'total_qty' => 3, 'items' => [ [ 'product_id' => $product->id, @@ -270,7 +278,11 @@ class PosApiTest extends TestCase // 2. Test deducting from NULL batch (default) $payloadNull = [ 'external_order_id' => 'ORD-BATCH-NULL', + 'name' => '無批號測試', 'warehouse_code' => 'MAIN', + 'payment_method' => 'cash', + 'total_amount' => 200, // 2 * 100 + 'total_qty' => 2, 'items' => [ [ 'product_id' => $product->id, @@ -423,7 +435,8 @@ class PosApiTest extends TestCase { tenancy()->initialize($this->tenant); $product = Product::where('code', 'P-001')->first(); - $warehouse = \App\Modules\Inventory\Models\Warehouse::where('code', 'MAIN')->first(); + // 確保倉庫存在 + $warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']); // 清空該商品的現有庫存以利測試負數 \App\Modules\Inventory\Models\Inventory::where('product_id', $product->id)->delete(); @@ -434,7 +447,11 @@ class PosApiTest extends TestCase $payload = [ 'external_order_id' => 'ORD-NEGATIVE-TEST', + 'name' => '負數庫存測試', 'warehouse_code' => 'MAIN', + 'payment_method' => 'cash', + 'total_amount' => 1000, + 'total_qty' => 10, 'items' => [ [ 'product_id' => $product->id, @@ -461,4 +478,60 @@ class PosApiTest extends TestCase tenancy()->end(); } + + public function test_order_source_automation_based_on_warehouse_type() + { + tenancy()->initialize($this->tenant); + $product = Product::where('code', 'P-001')->first(); + + // 1. Create a VENDING warehouse + $vendingWarehouse = \App\Modules\Inventory\Models\Warehouse::create([ + 'name' => 'Vending Machine 01', + 'code' => 'VEND-01', + 'type' => \App\Enums\WarehouseType::VENDING, + ]); + + // 2. Create a STANDARD warehouse + $standardWarehouse = \App\Modules\Inventory\Models\Warehouse::create([ + 'name' => 'General Warehouse', + 'code' => 'ST-01', + 'type' => \App\Enums\WarehouseType::STANDARD, + ]); + tenancy()->end(); + + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + // Case A: Vending Warehouse -> Source should be 'vending' + $payloadVending = [ + 'external_order_id' => 'ORD-VEND', + 'name' => 'Vending Order', + 'warehouse_code' => 'VEND-01', + 'total_amount' => 100, + 'total_qty' => 1, + 'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]] + ]; + + $this->withHeaders(['X-Tenant-Domain' => $this->domain]) + ->postJson('/api/v1/integration/orders', $payloadVending) + ->assertStatus(201); + + // Case B: Standard Warehouse -> Source should be 'pos' + $payloadPos = [ + 'external_order_id' => 'ORD-POS', + 'name' => 'POS Order', + 'warehouse_code' => 'ST-01', + 'total_amount' => 100, + 'total_qty' => 1, + 'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]] + ]; + + $this->withHeaders(['X-Tenant-Domain' => $this->domain]) + ->postJson('/api/v1/integration/orders', $payloadPos) + ->assertStatus(201); + + tenancy()->initialize($this->tenant); + $this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-VEND', 'source' => 'vending']); + $this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-POS', 'source' => 'pos']); + tenancy()->end(); + } }