diff --git a/.gitea/workflows/deploy-demo.yaml b/.gitea/workflows/deploy-demo.yaml index c2e25e2..00857fa 100644 --- a/.gitea/workflows/deploy-demo.yaml +++ b/.gitea/workflows/deploy-demo.yaml @@ -97,4 +97,4 @@ jobs: php artisan optimize && php artisan view:cache " - docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache + docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache \ No newline at end of file diff --git a/app/Modules/Finance/Controllers/AccountPayableController.php b/app/Modules/Finance/Controllers/AccountPayableController.php new file mode 100644 index 0000000..0e0588c --- /dev/null +++ b/app/Modules/Finance/Controllers/AccountPayableController.php @@ -0,0 +1,124 @@ +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', '帳款已成功標記為已付款'); + } +} diff --git a/app/Modules/Finance/FinanceServiceProvider.php b/app/Modules/Finance/FinanceServiceProvider.php index cff7ffa..75bd504 100644 --- a/app/Modules/Finance/FinanceServiceProvider.php +++ b/app/Modules/Finance/FinanceServiceProvider.php @@ -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 + ); } } diff --git a/app/Modules/Finance/Listeners/CreateAccountPayableFromGoodsReceipt.php b/app/Modules/Finance/Listeners/CreateAccountPayableFromGoodsReceipt.php new file mode 100644 index 0000000..29ba2ab --- /dev/null +++ b/app/Modules/Finance/Listeners/CreateAccountPayableFromGoodsReceipt.php @@ -0,0 +1,41 @@ +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; + } + } +} diff --git a/app/Modules/Finance/Models/AccountPayable.php b/app/Modules/Finance/Models/AccountPayable.php new file mode 100644 index 0000000..0658845 --- /dev/null +++ b/app/Modules/Finance/Models/AccountPayable.php @@ -0,0 +1,61 @@ + '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'); + } +} diff --git a/app/Modules/Finance/Routes/web.php b/app/Modules/Finance/Routes/web.php index 9cb8cfe..dd0a8cd 100644 --- a/app/Modules/Finance/Routes/web.php +++ b/app/Modules/Finance/Routes/web.php @@ -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'); diff --git a/app/Modules/Finance/Services/AccountPayableService.php b/app/Modules/Finance/Services/AccountPayableService.php new file mode 100644 index 0000000..1ae3dc8 --- /dev/null +++ b/app/Modules/Finance/Services/AccountPayableService.php @@ -0,0 +1,85 @@ +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; + } +} diff --git a/app/Modules/Inventory/Contracts/GoodsReceiptServiceInterface.php b/app/Modules/Inventory/Contracts/GoodsReceiptServiceInterface.php new file mode 100644 index 0000000..c44e222 --- /dev/null +++ b/app/Modules/Inventory/Contracts/GoodsReceiptServiceInterface.php @@ -0,0 +1,14 @@ + '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 diff --git a/app/Modules/Inventory/Controllers/StoreRequisitionController.php b/app/Modules/Inventory/Controllers/StoreRequisitionController.php index 4a61972..8507b47 100644 --- a/app/Modules/Inventory/Controllers/StoreRequisitionController.php +++ b/app/Modules/Inventory/Controllers/StoreRequisitionController.php @@ -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', '供貨倉庫已更新'); + } + /** * 刪除叫貨單(僅限草稿) */ diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index d40edcc..5832401 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -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']), ]); } diff --git a/app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php b/app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php new file mode 100644 index 0000000..2abe4df --- /dev/null +++ b/app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php @@ -0,0 +1,22 @@ +goodsReceiptId = $goodsReceiptId; + } +} diff --git a/app/Modules/Inventory/InventoryServiceProvider.php b/app/Modules/Inventory/InventoryServiceProvider.php index ddca8f5..3113dff 100644 --- a/app/Modules/Inventory/InventoryServiceProvider.php +++ b/app/Modules/Inventory/InventoryServiceProvider.php @@ -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 diff --git a/app/Modules/Inventory/Models/GoodsReceipt.php b/app/Modules/Inventory/Models/GoodsReceipt.php index 4f1fbdd..c4ba032 100644 --- a/app/Modules/Inventory/Models/GoodsReceipt.php +++ b/app/Modules/Inventory/Models/GoodsReceipt.php @@ -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', diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 33652b7..063d97f 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -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'); diff --git a/app/Modules/Inventory/Services/GoodsReceiptService.php b/app/Modules/Inventory/Services/GoodsReceiptService.php index fb2dd9c..6aa12aa 100644 --- a/app/Modules/Inventory/Services/GoodsReceiptService.php +++ b/app/Modules/Inventory/Services/GoodsReceiptService.php @@ -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(); + } } diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php index d290aff..3362773 100644 --- a/app/Modules/Inventory/Services/StoreRequisitionService.php +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -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, diff --git a/database/migrations/tenant/2026_02_24_134125_create_account_payables_table.php b/database/migrations/tenant/2026_02_24_134125_create_account_payables_table.php new file mode 100644 index 0000000..6ee89c6 --- /dev/null +++ b/database/migrations/tenant/2026_02_24_134125_create_account_payables_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('vendor_id')->constrained('vendors'); + $table->string('source_document_type')->comment('來源單據類型 (e.g. goods_receipt)'); + $table->unsignedBigInteger('source_document_id')->comment('來源單據 ID'); + $table->string('document_number')->unique()->comment('應付帳款單號'); + $table->decimal('total_amount', 15, 2)->comment('總金額 (含稅)'); + $table->decimal('tax_amount', 15, 2)->default(0)->comment('稅金'); + $table->string('status')->default('pending')->comment('狀態: pending, partially_paid, paid, cancelled'); + $table->date('due_date')->nullable()->comment('應付日期'); + $table->text('remarks')->nullable()->comment('備註'); + $table->foreignId('created_by')->nullable()->constrained('users'); + $table->timestamps(); + + $table->index(['source_document_type', 'source_document_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('account_payables'); + } +}; diff --git a/database/migrations/tenant/2026_02_24_151909_modify_status_in_goods_receipts_table_enum.php b/database/migrations/tenant/2026_02_24_151909_modify_status_in_goods_receipts_table_enum.php new file mode 100644 index 0000000..e01f9fe --- /dev/null +++ b/database/migrations/tenant/2026_02_24_151909_modify_status_in_goods_receipts_table_enum.php @@ -0,0 +1,28 @@ +string('status', 20)->default('draft')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('goods_receipts', function (Blueprint $table) { + $table->enum('status', ['draft', 'completed', 'cancelled'])->default('draft')->change(); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_24_162239_add_payment_and_invoice_to_account_payables_table.php b/database/migrations/tenant/2026_02_24_162239_add_payment_and_invoice_to_account_payables_table.php new file mode 100644 index 0000000..9e1dae8 --- /dev/null +++ b/database/migrations/tenant/2026_02_24_162239_add_payment_and_invoice_to_account_payables_table.php @@ -0,0 +1,39 @@ +string('invoice_number')->nullable()->after('due_date')->comment('發票號碼'); + $table->date('invoice_date')->nullable()->after('invoice_number')->comment('發票日期'); + + $table->timestamp('paid_at')->nullable()->after('status')->comment('付款時間'); + $table->string('payment_method')->nullable()->after('paid_at')->comment('付款方式'); + $table->text('payment_note')->nullable()->after('payment_method')->comment('付款備註'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('account_payables', function (Blueprint $table) { + $table->dropColumn([ + 'invoice_number', + 'invoice_date', + 'paid_at', + 'payment_method', + 'payment_note' + ]); + }); + } +}; diff --git a/fix_ap.php b/fix_ap.php new file mode 100644 index 0000000..ee49bd3 --- /dev/null +++ b/fix_ap.php @@ -0,0 +1,24 @@ +first(); +if ($ap) { + if (!$ap->total_amount || $ap->total_amount == 0) { + $sum = GoodsReceiptItem::where('goods_receipt_id', $ap->source_document_id)->sum('total_amount'); + if ($sum == 0) { + // fallback: check if unit_price * quantity_received works + $items = GoodsReceiptItem::where('goods_receipt_id', $ap->source_document_id)->get(); + foreach($items as $item) { + $sum += ($item->quantity_received * $item->unit_price); + } + } + $ap->total_amount = $sum; + $ap->save(); + echo "Fixed AP {$ap->document_number} total_amount to {$sum}\n"; + } else { + echo "AP total_amount is already set: {$ap->total_amount}\n"; + } +} else { + echo "No AP found\n"; +} diff --git a/resources/css/app.css b/resources/css/app.css index b53a858..0b0a4ac 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -250,6 +250,11 @@ border-color: var(--other-warning); } +.button-outlined-warning:hover { + @apply bg-amber-50 text-[var(--grey-0)]; + border-color: var(--other-warning); +} + .button-filled-error { @apply bg-[var(--button-err-normal)] text-[var(--grey-5)] border-transparent transition-colors shadow-sm; } diff --git a/resources/js/Components/Inventory/GoodsReceiptActions.tsx b/resources/js/Components/Inventory/GoodsReceiptActions.tsx index f336671..6b1ff7d 100644 --- a/resources/js/Components/Inventory/GoodsReceiptActions.tsx +++ b/resources/js/Components/Inventory/GoodsReceiptActions.tsx @@ -33,6 +33,7 @@ export default function GoodsReceiptActions({ receipt, }: { receipt: GoodsReceipt }) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const { delete: destroy, processing } = useForm({}); const handleConfirmDelete = () => { @@ -46,6 +47,8 @@ export default function GoodsReceiptActions({ }); }; + + return (
+ 追蹤與供應商的應付帳項,管理採購入庫後的結算與付款狀態。 +
++ {payable.vendor?.name || '未知供應商'} +
++ ${parseFloat(payable.total_amount).toLocaleString()} +
++ {formatDate(payable.due_date)} +
++ {payable.creator?.name || '-'} +
++ {formatDate(payable.created_at)} +
++ {payable.remarks} +
+
+ {payable.source_document_type === 'goods_receipt' ? (
+
+ {payable.source_document_code || payable.source_document_id || '-'} +
+ {payable.source_document_type === 'goods_receipt' && payable.source_document_id && ( + + 查閱+ {payable.invoice_number || 尚未登記} +
++ {payable.invoice_date ? formatDate(payable.invoice_date) : '-'} +
++ 已結清 +
++ {payable.payment_method === 'cash' && '現金'} + {payable.payment_method === 'bank_transfer' && '銀行轉帳'} + {payable.payment_method === 'check' && '支票'} + {payable.payment_method === 'credit_card' && '信用卡'} + {!['cash', 'bank_transfer', 'check', 'credit_card'].includes(payable.payment_method) && (payable.payment_method || '-')} +
++ {payable.paid_at ? formatDate(payable.paid_at) : '-'} +
++ {payable.payment_note} +
+單號:{receipt.code}
-單號:{order.poNumber}
-- {requisition.supply_warehouse_name || "-"} -
++ {requisition.supply_warehouse_name || "-"} +
+ )} +