feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
2026-01-26 14:59:24 +08:00
parent b0848a6bb8
commit 106de4e945
81 changed files with 4118 additions and 1023 deletions

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Modules\Finance\Contracts;
use Illuminate\Support\Collection;
interface FinanceServiceInterface
{
/**
* Get accounting report data.
*
* @param string $start
* @param string $end
* @return array
*/
public function getAccountingReportData(string $start, string $end): array;
/**
* Get all utility fees with filters.
*
* @param array $filters
* @return mixed
*/
public function getUtilityFees(array $filters);
/**
* Get unique categories of utility fees.
*
* @return Collection
*/
public function getUniqueCategories(): Collection;
}

View File

@@ -3,9 +3,7 @@
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Carbon;
@@ -13,49 +11,20 @@ use Illuminate\Pagination\LengthAwarePaginator;
class AccountingReportController extends Controller
{
protected $financeService;
public function __construct(FinanceServiceInterface $financeService)
{
$this->financeService = $financeService;
}
public function index(Request $request)
{
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
// 1. Get Purchase Orders (Completed or Received that are ready for accounting)
$purchaseOrders = PurchaseOrder::with(['vendor'])
->whereIn('status', ['received', 'completed'])
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
->get()
->map(function ($po) {
return [
'id' => 'PO-' . $po->id,
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
'source' => '採購單',
'category' => '進貨支出',
'item' => $po->vendor->name ?? '未知廠商',
'reference' => $po->code,
'invoice_number' => $po->invoice_number,
'amount' => $po->grand_total,
];
});
// 2. Get Utility Fees
$utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd])
->get()
->map(function ($fee) {
return [
'id' => 'UF-' . $fee->id,
'date' => $fee->transaction_date->format('Y-m-d'),
'source' => '公共事業費',
'category' => $fee->category,
'item' => $fee->description ?: $fee->category,
'reference' => '-',
'invoice_number' => $fee->invoice_number,
'amount' => $fee->amount,
];
});
// Combine and Sort
$allRecords = $purchaseOrders->concat($utilityFees)
->sortByDesc('date')
->values();
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
$allRecords = $reportData['records'];
// 3. Manual Pagination
$perPage = $request->input('per_page', 10);
@@ -70,16 +39,9 @@ class AccountingReportController extends Controller
['path' => $request->url(), 'query' => $request->query()]
);
$summary = [
'total_amount' => $allRecords->sum('amount'),
'purchase_total' => $purchaseOrders->sum('amount'),
'utility_total' => $utilityFees->sum('amount'),
'record_count' => $allRecords->count(),
];
return Inertia::render('Accounting/Report', [
'records' => $paginatedRecords,
'summary' => $summary,
'summary' => $reportData['summary'],
'filters' => [
'date_start' => $dateStart,
'date_end' => $dateEnd,
@@ -94,60 +56,25 @@ class AccountingReportController extends Controller
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
$selectedIdsParam = $request->input('selected_ids');
$purchaseOrdersQuery = PurchaseOrder::with(['vendor'])
->whereIn('status', ['received', 'completed']);
$utilityFeesQuery = UtilityFee::query();
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
$allRecords = $reportData['records'];
if ($selectedIdsParam) {
$ids = explode(',', $selectedIdsParam);
$poIds = [];
$ufIds = [];
foreach ($ids as $id) {
if (str_starts_with($id, 'PO-')) {
$poIds[] = substr($id, 3);
} elseif (str_starts_with($id, 'UF-')) {
$ufIds[] = substr($id, 3);
}
}
$purchaseOrders = $purchaseOrdersQuery->whereIn('id', $poIds)->get();
$utilityFees = $utilityFeesQuery->whereIn('id', $ufIds)->get();
} else {
$purchaseOrders = $purchaseOrdersQuery
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
->get();
$utilityFees = $utilityFeesQuery
->whereBetween('transaction_date', [$dateStart, $dateEnd])
->get();
$allRecords = $allRecords->whereIn('id', $ids);
}
$allRecords = collect();
foreach ($purchaseOrders as $po) {
$allRecords->push([
Carbon::parse($po->created_at)->toDateString(),
'採購單',
'進貨支出',
$po->vendor->name ?? '',
$po->code,
$po->invoice_number,
(float)$po->grand_total,
]);
}
foreach ($utilityFees as $fee) {
$allRecords->push([
Carbon::parse($fee->transaction_date)->toDateString(),
'公共事業費',
$fee->category,
$fee->description,
'-',
$fee->invoice_number,
(float)$fee->amount,
]);
}
$allRecords = $allRecords->sortByDesc(0);
$exportData = $allRecords->map(function ($record) {
return [
$record['date'],
$record['source'],
$record['category'],
$record['item'],
$record['reference'],
$record['invoice_number'],
$record['amount'],
];
});
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
$headers = [
@@ -155,14 +82,14 @@ class AccountingReportController extends Controller
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function () use ($allRecords) {
$callback = function () use ($exportData) {
$file = fopen('php://output', 'w');
// BOM for Excel compatibility with UTF-8
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
foreach ($allRecords as $row) {
foreach ($exportData as $row) {
fputcsv($file, $row);
}
fclose($file);

View File

@@ -4,57 +4,30 @@ namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
class UtilityFeeController extends Controller
{
protected $financeService;
public function __construct(FinanceServiceInterface $financeService)
{
$this->financeService = $financeService;
}
public function index(Request $request)
{
$query = UtilityFee::query();
// Search
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('category', 'like', "%{$search}%")
->orWhere('invoice_number', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
// Filtering
if ($request->filled('category') && $request->input('category') !== 'all') {
$query->where('category', $request->input('category'));
}
if ($request->filled('date_start')) {
$query->where('transaction_date', '>=', $request->input('date_start'));
}
if ($request->filled('date_end')) {
$query->where('transaction_date', '<=', $request->input('date_end'));
}
// Sorting
$sortField = $request->input('sort_field');
$sortDirection = $request->input('sort_direction');
if ($sortField && $sortDirection) {
$query->orderBy($sortField, $sortDirection);
} else {
$query->orderBy('created_at', 'desc');
}
$fees = $query->paginate($request->input('per_page', 10))->withQueryString();
$availableCategories = UtilityFee::distinct()->pluck('category');
$filters = $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']);
$fees = $this->financeService->getUtilityFees($filters)->withQueryString();
$availableCategories = $this->financeService->getUniqueCategories();
return Inertia::render('UtilityFee/Index', [
'fees' => $fees,
'availableCategories' => $availableCategories,
'filters' => $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
'filters' => $filters,
]);
}
@@ -70,19 +43,10 @@ class UtilityFeeController extends Controller
$fee = UtilityFee::create($validated);
// Log activity
activity()
->performedOn($fee)
->causedBy(auth()->user())
->event('created')
->withProperties([
'attributes' => $fee->getAttributes(),
'snapshot' => [
'category' => $fee->category,
'amount' => $fee->amount,
'transaction_date' => $fee->transaction_date->format('Y-m-d'),
]
])
->log('created');
return redirect()->back();
@@ -98,52 +62,12 @@ class UtilityFeeController extends Controller
'description' => 'nullable|string',
]);
// Capture old attributes before update
$oldAttributes = $utility_fee->getAttributes();
$utility_fee->update($validated);
// Capture new attributes
$newAttributes = $utility_fee->getAttributes();
// Manual logOnlyDirty: Filter attributes to only include changes
$changedAttributes = [];
$changedOldAttributes = [];
foreach ($newAttributes as $key => $value) {
// Skip timestamps if they are the only change (optional, but good practice)
if (in_array($key, ['updated_at'])) continue;
$oldValue = $oldAttributes[$key] ?? null;
// Simple comparison (casting to string to handle date objects vs strings if necessary,
// but Eloquent attributes are usually consistent if casted.
// Using loose comparison != handles most cases correctly)
if ($value != $oldValue) {
$changedAttributes[$key] = $value;
$changedOldAttributes[$key] = $oldValue;
}
}
// Only log if there are changes (excluding just updated_at)
if (empty($changedAttributes)) {
return redirect()->back();
}
// Log activity with before/after comparison
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => $changedAttributes,
'old' => $changedOldAttributes,
'snapshot' => [
'category' => $utility_fee->category,
'amount' => $utility_fee->amount,
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
]
])
->log('updated');
return redirect()->back();
@@ -151,24 +75,10 @@ class UtilityFeeController extends Controller
public function destroy(UtilityFee $utility_fee)
{
// Capture data snapshot before deletion
$snapshot = [
'category' => $utility_fee->category,
'amount' => $utility_fee->amount,
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
'invoice_number' => $utility_fee->invoice_number,
'description' => $utility_fee->description,
];
// Log activity before deletion
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('deleted')
->withProperties([
'attributes' => $utility_fee->getAttributes(),
'snapshot' => $snapshot
])
->log('deleted');
$utility_fee->delete();

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Finance;
use Illuminate\Support\ServiceProvider;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use App\Modules\Finance\Services\FinanceService;
class FinanceServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(FinanceServiceInterface::class, FinanceService::class);
}
public function boot(): void
{
//
}
}

View File

@@ -11,26 +11,25 @@ class UtilityFee extends Model
use HasFactory;
protected $fillable = [
'type', // 'electricity', 'water', 'gas', etc.
'billing_period_start',
'billing_period_end',
'due_date',
'transaction_date',
'category',
'amount',
'usage_amount', // kWh, m3, etc.
'unit', // 度, 立方米
'status', // 'pending', 'paid', 'overdue'
'paid_at',
'payment_method',
'notes',
'receipt_image_path',
'invoice_number',
'description',
];
protected $casts = [
'billing_period_start' => 'date',
'billing_period_end' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
'transaction_date' => 'date',
'amount' => 'decimal:2',
'usage_amount' => 'decimal:2',
];
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$activity->properties = $activity->properties->put('snapshot', [
'transaction_date' => $this->transaction_date->format('Y-m-d'),
'category' => $this->category,
'amount' => $this->amount,
'invoice_number' => $this->invoice_number,
]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Modules\Finance\Services;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
class FinanceService implements FinanceServiceInterface
{
protected $procurementService;
public function __construct(ProcurementServiceInterface $procurementService)
{
$this->procurementService = $procurementService;
}
public function getAccountingReportData(string $start, string $end): array
{
// 1. 獲取採購單資料
$purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end)
->map(function ($po) {
return [
'id' => 'PO-' . $po->id,
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
'source' => '採購單',
'category' => '進貨支出',
'item' => $po->vendor->name ?? '未知廠商',
'reference' => $po->code,
'invoice_number' => $po->invoice_number,
'amount' => (float)$po->grand_total,
];
});
// 2. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date)
$utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end])
->get()
->map(function ($fee) {
return [
'id' => 'UF-' . $fee->id,
'date' => $fee->transaction_date->format('Y-m-d'),
'source' => '公共事業費',
'category' => $fee->category,
'item' => $fee->description ?: $fee->category,
'reference' => '-',
'invoice_number' => $fee->invoice_number,
'amount' => (float)$fee->amount,
];
});
$allRecords = $purchaseOrders->concat($utilityFees)
->sortByDesc('date')
->values();
return [
'records' => $allRecords,
'summary' => [
'total_amount' => $allRecords->sum('amount'),
'purchase_total' => $purchaseOrders->sum('amount'),
'utility_total' => $utilityFees->sum('amount'),
'record_count' => $allRecords->count(),
]
];
}
public function getUtilityFees(array $filters)
{
$query = UtilityFee::query();
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function($q) use ($search) {
$q->where('category', 'like', "%{$search}%")
->orWhere('invoice_number', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if (!empty($filters['category']) && $filters['category'] !== 'all') {
$query->where('category', $filters['category']);
}
if (!empty($filters['date_start'])) {
$query->where('transaction_date', '>=', $filters['date_start']);
}
if (!empty($filters['date_end'])) {
$query->where('transaction_date', '<=', $filters['date_end']);
}
$sortField = $filters['sort_field'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
return $query->paginate($filters['per_page'] ?? 10);
}
public function getUniqueCategories(): Collection
{
return UtilityFee::distinct()->pluck('category');
}
}