feat: 完成進貨單自動拋轉應付帳款流程與AP介面優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s

1. 新增 AccountPayable (應付帳款) 模組,包含 Migration、Model、Service 與 Controller
2. 修改 GoodsReceipt (進貨單) 流程,在確認進貨時自動產生對應的應付帳款單 (AP-YYYYMMDD-XX)
3. 實作應付帳款詳細頁面 (Show.tsx),包含發票登記與標記付款功能
4. 修正應付帳款 Show 頁面的排版,將發票資訊套用標準的綠色背景區塊,並調整按鈕位置
5. 更新相關的 Service Provider 與 Routes
This commit is contained in:
2026-02-24 16:46:55 +08:00
parent aaa93a921e
commit 455f945296
33 changed files with 1708 additions and 186 deletions

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Modules\Inventory\Contracts;
interface GoodsReceiptServiceInterface
{
/**
* 獲取指定的進貨單資訊
*
* @param int $goodsReceiptId
* @return array|null 返回進貨單的純陣列資料,若找不到則回傳 null
*/
public function getGoodsReceiptData(int $goodsReceiptId): ?array;
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Http\Request;
use App\Modules\Procurement\Models\Vendor;
use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt;
use Illuminate\Support\Facades\DB;
class GoodsReceiptController extends Controller
{
@@ -174,9 +175,26 @@ class GoodsReceiptController extends Controller
'items.*.expiry_date' => 'nullable|date',
]);
$this->goodsReceiptService->store($validated);
try {
$this->goodsReceiptService->store($request->all());
return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
return redirect()->route('goods-receipts.index')->with('success', '進貨單已建立');
public function submit(GoodsReceipt $goodsReceipt)
{
if (!auth()->user()->can('goods_receipts.update')) {
return back()->with('error', '您沒有權限確認點收');
}
try {
$this->goodsReceiptService->submit($goodsReceipt);
return back()->with('success', '進貨單已點收完成,庫存已增加並拋轉應付帳款');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
// API to search POs

View File

@@ -299,15 +299,16 @@ class StoreRequisitionController extends Controller
$requisition = StoreRequisition::findOrFail($id);
$request->validate([
'supply_warehouse_id' => 'required|exists:warehouses,id',
'items' => 'required|array',
'items.*.id' => 'required|exists:store_requisition_items,id',
'items.*.approved_qty' => 'required|numeric|min:0',
], [
'supply_warehouse_id.required' => '請選擇供貨倉庫',
]);
$this->service->approve($requisition, $request->only(['supply_warehouse_id', 'items']), auth()->id());
if (empty($requisition->supply_warehouse_id)) {
return back()->withErrors(['supply_warehouse_id' => '請先選擇供貨倉庫']);
}
$this->service->approve($requisition, $request->only(['items']), auth()->id());
return redirect()->route('store-requisitions.show', $id)
->with('success', '叫貨單已核准,調撥單已自動產生');
@@ -332,6 +333,28 @@ class StoreRequisitionController extends Controller
->with('success', '叫貨單已駁回');
}
/**
* 更新供貨倉庫
*/
public function updateSupplyWarehouse(Request $request, $id)
{
$requisition = StoreRequisition::findOrFail($id);
if ($requisition->status !== 'pending') {
return back()->withErrors(['error' => '僅能在待審核狀態修改供貨倉庫']);
}
$request->validate([
'supply_warehouse_id' => 'required|exists:warehouses,id',
]);
$requisition->update([
'supply_warehouse_id' => $request->supply_warehouse_id,
]);
return redirect()->back()->with('success', '供貨倉庫已更新');
}
/**
* 刪除叫貨單(僅限草稿)
*/

View File

@@ -26,6 +26,14 @@ class TransferOrderController extends Controller
$query = InventoryTransferOrder::query()
->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
// 搜尋:單號或備註
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('doc_no', 'like', "%{$request->search}%")
->orWhere('remarks', 'like', "%{$request->search}%");
});
}
// 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單
if ($request->filled('warehouse_id')) {
$query->where(function ($q) use ($request) {
@@ -54,7 +62,7 @@ class TransferOrderController extends Controller
return Inertia::render('Inventory/Transfer/Index', [
'orders' => $orders,
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id', 'per_page']),
'filters' => $request->only(['search', 'warehouse_id', 'per_page']),
]);
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Modules\Inventory\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GoodsReceiptApprovedEvent
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $goodsReceiptId;
/**
* Create a new event instance.
*/
public function __construct(int $goodsReceiptId)
{
$this->goodsReceiptId = $goodsReceiptId;
}
}

View File

@@ -14,6 +14,10 @@ class InventoryServiceProvider extends ServiceProvider
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
$this->app->bind(ProductServiceInterface::class, ProductService::class);
$this->app->bind(
\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class,
\App\Modules\Inventory\Services\GoodsReceiptService::class
);
}
public function boot(): void

View File

@@ -11,6 +11,11 @@ class GoodsReceipt extends Model
use HasFactory, SoftDeletes;
use \Spatie\Activitylog\Traits\LogsActivity;
public const STATUS_DRAFT = 'draft';
public const STATUS_PENDING_AUDIT = 'pending_audit';
public const STATUS_COMPLETED = 'completed';
public const STATUS_REJECTED = 'rejected';
protected $fillable = [
'code',
'type',

View File

@@ -168,6 +168,7 @@ Route::middleware('auth')->group(function () {
Route::middleware('permission:store_requisitions.approve')->group(function () {
Route::post('/store-requisitions/{id}/approve', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'approve'])->name('store-requisitions.approve');
Route::post('/store-requisitions/{id}/reject', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'reject'])->name('store-requisitions.reject');
Route::patch('/store-requisitions/{id}/supply-warehouse', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'updateSupplyWarehouse'])->name('store-requisitions.update-supply-warehouse');
});
Route::delete('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'destroy'])->middleware('permission:store_requisitions.delete')->name('store-requisitions.destroy');
@@ -179,6 +180,16 @@ Route::middleware('auth')->group(function () {
Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create');
Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show');
Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
// 點收提交路由
Route::post('/goods-receipts/{goods_receipt}/submit', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'submit'])
->middleware('permission:goods_receipts.update')
->name('goods-receipts.submit');
Route::delete('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'destroy'])
->middleware('permission:goods_receipts.delete')
->name('goods-receipts.destroy');
Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos');
Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products');
Route::get('/api/goods-receipts/search-vendors', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchVendors'])->name('goods-receipts.search-vendors');

View File

@@ -7,8 +7,10 @@ use App\Modules\Inventory\Models\GoodsReceiptItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Facades\DB;
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
use Illuminate\Support\Facades\Log;
class GoodsReceiptService
class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface
{
protected $inventoryService;
protected $procurementService;
@@ -22,7 +24,7 @@ class GoodsReceiptService
}
/**
* Store a new Goods Receipt and process inventory.
* Store a new Goods Receipt (Draft state).
*
* @param array $data
* @return GoodsReceipt
@@ -34,7 +36,7 @@ class GoodsReceiptService
// 1. Generate Code
$data['code'] = $this->generateCode($data['received_date']);
$data['user_id'] = auth()->id();
$data['status'] = 'completed'; // Direct completion for now
$data['status'] = GoodsReceipt::STATUS_DRAFT; // 預設草稿
// 2. Create Header
$goodsReceipt = GoodsReceipt::create($data);
@@ -52,8 +54,76 @@ class GoodsReceiptService
'expiry_date' => $itemData['expiry_date'] ?? null,
]);
$goodsReceipt->items()->save($grItem);
}
// 4. Update Inventory
return $goodsReceipt;
});
}
/**
* Update an existing Goods Receipt.
*
* @param GoodsReceipt $goodsReceipt
* @param array $data
* @return GoodsReceipt
* @throws \Exception
*/
public function update(GoodsReceipt $goodsReceipt, array $data)
{
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
throw new \Exception('只有草稿或被退回的進貨單可以修改。');
}
return DB::transaction(function () use ($goodsReceipt, $data) {
$goodsReceipt->update([
'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id,
'received_date' => $data['received_date'] ?? $goodsReceipt->received_date,
'remarks' => $data['remarks'] ?? $goodsReceipt->remarks,
]);
if (isset($data['items'])) {
// Simple strategy: delete existing items and recreate
$goodsReceipt->items()->delete();
foreach ($data['items'] as $itemData) {
$grItem = new GoodsReceiptItem([
'product_id' => $itemData['product_id'],
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
'quantity_received' => $itemData['quantity_received'],
'unit_price' => $itemData['unit_price'],
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
'batch_number' => $itemData['batch_number'] ?? null,
'expiry_date' => $itemData['expiry_date'] ?? null,
]);
$goodsReceipt->items()->save($grItem);
}
}
return $goodsReceipt->fresh('items');
});
}
/**
* Submit for audit (Confirm receipt by warehouse staff).
* This will increase inventory and update PO.
*
* @param GoodsReceipt $goodsReceipt
* @return GoodsReceipt
* @throws \Exception
*/
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) {
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
$goodsReceipt->save();
// Process Inventory and PO updates
foreach ($goodsReceipt->items as $grItem) {
// 1. Update Inventory
$reason = match($goodsReceipt->type) {
'standard' => '採購進貨',
'miscellaneous' => '雜項入庫',
@@ -75,7 +145,7 @@ class GoodsReceiptService
'arrival_date' => $goodsReceipt->received_date,
]);
// 5. Update PO if linked and type is standard
// 2. Update PO if linked and type is standard
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) {
$this->procurementService->updateReceivedQuantity(
$grItem->purchase_order_item_id,
@@ -84,10 +154,14 @@ class GoodsReceiptService
}
}
// Fire event to let Finance module create AP
event(new GoodsReceiptApprovedEvent($goodsReceipt->id));
return $goodsReceipt;
});
}
private function generateCode(string $date)
{
// Format: GR-YYYYMMDD-NN
@@ -106,4 +180,22 @@ class GoodsReceiptService
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
}
/**
* 獲取指定的進貨單資訊 (實作 GoodsReceiptServiceInterface)
*
* @param int $goodsReceiptId
* @return array|null
*/
public function getGoodsReceiptData(int $goodsReceiptId): ?array
{
$receipt = GoodsReceipt::with('items')->find($goodsReceiptId);
if (!$receipt) {
return null;
}
// 以陣列形式回傳資料,避免外部模組產生 Model 依賴
return $receipt->toArray();
}
}

View File

@@ -127,13 +127,22 @@ class StoreRequisitionService
}
}
// 優先使用傳入的供貨倉庫,若無則從單據中取得
$supplyWarehouseId = $data['supply_warehouse_id'] ?? $requisition->supply_warehouse_id;
if (!$supplyWarehouseId) {
throw ValidationException::withMessages([
'supply_warehouse_id' => '請指定供貨倉庫',
]);
}
// 查詢供貨倉庫是否有預設在途倉
$supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($data['supply_warehouse_id']);
$supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($supplyWarehouseId);
$defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id;
// 產生調撥單(供貨倉庫 → 門市倉庫)
$transferOrder = $this->transferService->createOrder(
fromWarehouseId: $data['supply_warehouse_id'],
fromWarehouseId: $supplyWarehouseId,
toWarehouseId: $requisition->store_warehouse_id,
remarks: "由叫貨單 {$requisition->doc_no} 自動產生",
userId: $userId,
@@ -160,7 +169,7 @@ class StoreRequisitionService
// 更新叫貨單狀態
$requisition->update([
'status' => 'approved',
'supply_warehouse_id' => $data['supply_warehouse_id'],
'supply_warehouse_id' => $supplyWarehouseId,
'approved_by' => $userId,
'approved_at' => now(),
'transfer_order_id' => $transferOrder->id,