feat: 完成進貨單自動拋轉應付帳款流程與AP介面優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s
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:
124
app/Modules/Finance/Controllers/AccountPayableController.php
Normal file
124
app/Modules/Finance/Controllers/AccountPayableController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Finance\Models\AccountPayable;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class AccountPayableController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = AccountPayable::with(['vendor', 'creator']);
|
||||
|
||||
// 關鍵字搜尋 (單號、供應商名稱)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('document_number', 'like', "%{$search}%")
|
||||
->orWhereHas('vendor', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態過濾
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// 供應商過濾
|
||||
if ($request->filled('vendor_id') && $request->vendor_id !== 'all') {
|
||||
$query->where('vendor_id', $request->vendor_id);
|
||||
}
|
||||
|
||||
// 日期區間過濾
|
||||
if ($request->filled('date_start')) {
|
||||
$query->where('due_date', '>=', $request->date_start);
|
||||
}
|
||||
if ($request->filled('date_end')) {
|
||||
$query->where('due_date', '<=', $request->date_end);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$payables = $query->latest()->paginate($perPage)->withQueryString();
|
||||
|
||||
$vendors = \App\Modules\Procurement\Models\Vendor::select('id', 'name')->get();
|
||||
|
||||
return Inertia::render('AccountPayable/Index', [
|
||||
'payables' => $payables,
|
||||
'filters' => $request->all(['search', 'status', 'vendor_id', 'date_start', 'date_end', 'per_page']),
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(AccountPayable $accountPayable)
|
||||
{
|
||||
$accountPayable->load(['vendor', 'creator']);
|
||||
|
||||
// 嘗試加載來源單據資訊 (目前支援 goods_receipt)
|
||||
$sourceDocumentCode = null;
|
||||
if ($accountPayable->source_document_type === 'goods_receipt') {
|
||||
$receipt = \App\Modules\Inventory\Models\GoodsReceipt::find($accountPayable->source_document_id);
|
||||
if ($receipt) {
|
||||
$sourceDocumentCode = $receipt->code;
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('AccountPayable/Show', [
|
||||
// 將 model 轉換成 array 加入額外資訊
|
||||
'payable' => array_merge($accountPayable->toArray(), ['source_document_code' => $sourceDocumentCode]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新發票資訊
|
||||
*/
|
||||
public function updateInvoice(Request $request, AccountPayable $accountPayable)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'invoice_number' => 'nullable|string|max:50',
|
||||
'invoice_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$accountPayable->update([
|
||||
'invoice_number' => $validated['invoice_number'],
|
||||
'invoice_date' => $validated['invoice_date'],
|
||||
]);
|
||||
|
||||
return back()->with('success', '發票資訊已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 標記已付款
|
||||
*/
|
||||
public function pay(Request $request, AccountPayable $accountPayable)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'payment_method' => 'required|string|max:50',
|
||||
'paid_at' => 'required|date',
|
||||
'payment_note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($accountPayable->status === AccountPayable::STATUS_PAID) {
|
||||
return back()->with('error', '該帳款已經標記為已付款');
|
||||
}
|
||||
|
||||
$accountPayable->update([
|
||||
'status' => AccountPayable::STATUS_PAID,
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'paid_at' => $validated['paid_at'],
|
||||
'payment_note' => $validated['payment_note'],
|
||||
]);
|
||||
|
||||
return back()->with('success', '帳款已成功標記為已付款');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ class FinanceServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
\Illuminate\Support\Facades\Event::listen(
|
||||
\App\Modules\Inventory\Events\GoodsReceiptApprovedEvent::class,
|
||||
\App\Modules\Finance\Listeners\CreateAccountPayableFromGoodsReceipt::class
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Listeners;
|
||||
|
||||
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
|
||||
use App\Modules\Finance\Services\AccountPayableService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CreateAccountPayableFromGoodsReceipt
|
||||
{
|
||||
protected AccountPayableService $accountPayableService;
|
||||
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct(AccountPayableService $accountPayableService)
|
||||
{
|
||||
$this->accountPayableService = $accountPayableService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(GoodsReceiptApprovedEvent $event): void
|
||||
{
|
||||
try {
|
||||
// 目前使用系統預設 User ID 或 0 作為自動生成的建立者,若能從 event 取得更好
|
||||
$userId = auth()->id() ?? 1; // 假設 1 為系統管理員或預設使用者
|
||||
|
||||
$this->accountPayableService->createFromGoodsReceipt($event->goodsReceiptId, $userId);
|
||||
|
||||
Log::info("已成功為進貨單 ID: {$event->goodsReceiptId} 建立應付帳款");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("建立應付帳款失敗 (進貨單 ID: {$event->goodsReceiptId}): " . $e->getMessage());
|
||||
// 根據需求決定是否拋出 exception 或只記錄 log
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Modules/Finance/Models/AccountPayable.php
Normal file
61
app/Modules/Finance/Models/AccountPayable.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AccountPayable extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PARTIALLY_PAID = 'partially_paid';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'source_document_type',
|
||||
'source_document_id',
|
||||
'document_number',
|
||||
'total_amount',
|
||||
'tax_amount',
|
||||
'status',
|
||||
'due_date',
|
||||
'invoice_number',
|
||||
'invoice_date',
|
||||
'paid_at',
|
||||
'payment_method',
|
||||
'payment_note',
|
||||
'remarks',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'due_date' => 'date',
|
||||
'invoice_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 關聯:供應商
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function vendor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Procurement\Models\Vendor::class, 'vendor_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:建立者
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Core\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,17 @@ use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Finance\Controllers\UtilityFeeController;
|
||||
use App\Modules\Finance\Controllers\AccountingReportController;
|
||||
|
||||
use App\Modules\Finance\Controllers\AccountPayableController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
// 應付帳款
|
||||
Route::group(['prefix' => 'finance'], function () {
|
||||
Route::get('/account-payables', [AccountPayableController::class, 'index'])->name('account-payables.index');
|
||||
Route::get('/account-payables/{accountPayable}', [AccountPayableController::class, 'show'])->name('account-payables.show');
|
||||
Route::post('/account-payables/{accountPayable}/invoice', [AccountPayableController::class, 'updateInvoice'])->name('account-payables.invoice');
|
||||
Route::post('/account-payables/{accountPayable}/pay', [AccountPayableController::class, 'pay'])->name('account-payables.pay');
|
||||
});
|
||||
|
||||
// 公共事業費管理
|
||||
Route::middleware('permission:utility_fees.view')->group(function () {
|
||||
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
|
||||
|
||||
85
app/Modules/Finance/Services/AccountPayableService.php
Normal file
85
app/Modules/Finance/Services/AccountPayableService.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Services;
|
||||
|
||||
use App\Modules\Finance\Models\AccountPayable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface;
|
||||
|
||||
class AccountPayableService
|
||||
{
|
||||
protected GoodsReceiptServiceInterface $goodsReceiptService;
|
||||
|
||||
public function __construct(GoodsReceiptServiceInterface $goodsReceiptService)
|
||||
{
|
||||
$this->goodsReceiptService = $goodsReceiptService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據進貨單建立應付帳款
|
||||
*
|
||||
* @param int $goodsReceiptId
|
||||
* @param int $userId 執行操作的使用者 ID
|
||||
* @return AccountPayable
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createFromGoodsReceipt(int $goodsReceiptId, int $userId): AccountPayable
|
||||
{
|
||||
// 透過 Contract 取得 Inventory 模組的資料,避免直接依賴 Model
|
||||
$receiptData = $this->goodsReceiptService->getGoodsReceiptData($goodsReceiptId);
|
||||
|
||||
if (!$receiptData) {
|
||||
throw new \Exception("找不到對應的進貨單資料 (ID: {$goodsReceiptId})");
|
||||
}
|
||||
|
||||
// 檢查是否已經建立過(密等性)
|
||||
$existingAp = AccountPayable::where('source_document_type', 'goods_receipt')
|
||||
->where('source_document_id', $goodsReceiptId)
|
||||
->first();
|
||||
|
||||
if ($existingAp) {
|
||||
return $existingAp;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($receiptData, $userId) {
|
||||
$ap = AccountPayable::create([
|
||||
'vendor_id' => $receiptData['vendor_id'],
|
||||
'source_document_type' => 'goods_receipt',
|
||||
'source_document_id' => $receiptData['id'],
|
||||
'document_number' => $this->generateApNumber(),
|
||||
'total_amount' => collect($receiptData['items'] ?? [])->sum('total_amount'),
|
||||
'tax_amount' => 0, // 假設後續會實作稅額計算,目前預設為 0
|
||||
'status' => AccountPayable::STATUS_PENDING,
|
||||
// 設定應付日期,預設為進貨後 30 天 (可依據供應商設定調整)
|
||||
'due_date' => now()->addDays(30)->toDateString(),
|
||||
'created_by' => $userId,
|
||||
'remarks' => "由進貨單 {$receiptData['code']} 自動生成",
|
||||
]);
|
||||
|
||||
return $ap;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生應付帳款單號
|
||||
*/
|
||||
protected function generateApNumber(): string
|
||||
{
|
||||
$prefix = 'AP-' . date('Ymd') . '-';
|
||||
$lastPrefix = "{$prefix}%";
|
||||
|
||||
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
||||
->orderBy('document_number', 'desc')
|
||||
->first();
|
||||
|
||||
if (!$latest) {
|
||||
return $prefix . '01';
|
||||
}
|
||||
|
||||
$parts = explode('-', $latest->document_number);
|
||||
$lastNumber = intval(end($parts));
|
||||
$newNumber = str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT);
|
||||
|
||||
return $prefix . $newNumber;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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', '供貨倉庫已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除叫貨單(僅限草稿)
|
||||
*/
|
||||
|
||||
@@ -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']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
22
app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php
Normal file
22
app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user