phpvms/app/Services/Finance/PirepFinanceService.php
B.Fatih KOZ 4fe323a763
Advanced Fuel Calculations (#1044)
* Advanced Fuel Calculations

PR aims to have realistic fuel debit calculations as in the real ops. When enabled, remaining fuel amounts from previous flight will be reduced from block fuel and airline will only pay for uplifted fuel amount.

If onboard fuel is enough for the flight or exceeds the required amount no fuel debit will be issued.

If this is the first flight of the aircraft or the tanks are dry, full block fuel will be treated as uplifted.

Disabled / uses current default (fuel_used will be debited)

Aircraft table also will hold the fuel_onboard value (not needed for calculations, just saving for displaying purposes)

* Style Fix

* Another Style Fix

Love StyleCi

* Fix Settings

Moved new setting under other pirep settings

* Fix Setting Check

* Fix EOF settings.yml

* Fix Settings
2021-02-24 12:22:52 -05:00

623 lines
20 KiB
PHP

<?php
namespace App\Services\Finance;
use App\Contracts\Service;
use App\Events\Expenses as ExpensesEvent;
use App\Models\Aircraft;
use App\Models\Airport;
use App\Models\Enums\ExpenseType;
use App\Models\Enums\FareType;
use App\Models\Enums\PirepSource;
use App\Models\Expense;
use App\Models\Pirep;
use App\Models\Subfleet;
use App\Repositories\ExpenseRepository;
use App\Repositories\JournalRepository;
use App\Services\FareService;
use App\Services\FinanceService;
use App\Support\Math;
use App\Support\Money;
use Illuminate\Support\Facades\Log;
class PirepFinanceService extends Service
{
private $expenseRepo;
private $fareSvc;
private $financeSvc;
private $journalRepo;
/**
* FinanceService constructor.
*
* @param ExpenseRepository $expenseRepo
* @param FareService $fareSvc
* @param JournalRepository $journalRepo
* @param FinanceService $financeSvc
*/
public function __construct(
ExpenseRepository $expenseRepo,
FareService $fareSvc,
FinanceService $financeSvc,
JournalRepository $journalRepo
) {
$this->expenseRepo = $expenseRepo;
$this->fareSvc = $fareSvc;
$this->journalRepo = $journalRepo;
$this->financeSvc = $financeSvc;
}
/**
* Process all of the finances for a pilot report. This is called
* from a listener (FinanceEvents)
*
* @param Pirep $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
* @throws \Prettus\Validator\Exceptions\ValidatorException
* @throws \Exception
*
* @return mixed
*/
public function processFinancesForPirep(Pirep $pirep)
{
if (!$pirep->airline->journal) {
$pirep->airline->journal = $pirep->airline->initJournal(setting('units.currency', 'USD'));
}
if (!$pirep->user->journal) {
$pirep->user->journal = $pirep->user->initJournal(setting('units.currency', 'USD'));
}
// Clean out the expenses first
$this->deleteFinancesForPirep($pirep);
Log::info('Finance: Starting PIREP pay for '.$pirep->id);
// Now start and pay from scratch
$this->payFuelCosts($pirep);
$this->payFaresForPirep($pirep);
$this->payExpensesForSubfleet($pirep);
$this->payExpensesForPirep($pirep);
$this->payAirportExpensesForPirep($pirep);
$this->payExpensesEventsForPirep($pirep);
$this->payGroundHandlingForPirep($pirep);
$this->payPilotForPirep($pirep);
$pirep->airline->journal->refresh();
$pirep->user->journal->refresh();
// Recalculate balances...
$this->journalRepo->recalculateBalance($pirep->airline->journal);
$this->journalRepo->recalculateBalance($pirep->user->journal);
return $pirep;
}
/**
* @param Pirep $pirep
*/
public function deleteFinancesForPirep(Pirep $pirep): void
{
$this->journalRepo->deleteAllForObject($pirep);
}
/**
* Collect all of the fares and then post each fare class's profit and
* the costs for each seat and post it to the journal
*
* @param $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
* @throws \Prettus\Validator\Exceptions\ValidatorException
*/
public function payFaresForPirep($pirep): void
{
$fares = $this->getReconciledFaresForPirep($pirep);
/** @var \App\Models\Fare $fare */
foreach ($fares as $fare) {
Log::info('Finance: PIREP: '.$pirep->id.', Fare:', $fare->toArray());
$credit = Money::createFromAmount($fare->count * $fare->price);
$debit = Money::createFromAmount($fare->count * $fare->cost);
Log::info('Finance: Calculate: C='.$credit->toAmount().', D='.$debit->toAmount());
$memo = FareType::label($fare->type).' fare: '.$fare->code.': '.$fare->count
.'; price: '.$fare->price.', cost: '.$fare->cost;
$this->journalRepo->post(
$pirep->airline->journal,
$credit,
$debit,
$pirep,
$memo,
null,
'Fares',
'fare'
);
}
}
/**
* Calculate the fuel used by the PIREP and add those costs in
*
* @param Pirep $pirep
*
* @throws \Prettus\Validator\Exceptions\ValidatorException
*/
public function payFuelCosts(Pirep $pirep): void
{
$ap = $pirep->dpt_airport;
if (setting('pirep.advanced_fuel', false)) {
$ac = $pirep->aircraft;
// Reading second row by skip(1) to reach the previous accepted pirep. Current pirep is at the first row
$prev_flight = Pirep::where('aircraft_id', $ac->id)->where('state', 2)->where('status', 'ONB')->orderby('submitted_at', 'desc')->skip(1)->first();
if ($prev_flight) {
// If there is a pirep use its values to calculate the remaining fuel
// and calculate the uplifted fuel amount for this pirep
$fuel_amount = $pirep->block_fuel - ($prev_flight->block_fuel - $prev_flight->fuel_used);
// Aircraft has more than enough fuel in its tanks, no uplift necessary
if ($fuel_amount < 0) {
$fuel_amount = 0;
}
} else {
// No pirep found for aircraft, debit full block fuel
$fuel_amount = $pirep->block_fuel;
}
} else {
// Setting is false, switch back to basic calculation
$fuel_amount = $pirep->fuel_used;
}
$debit = Money::createFromAmount($fuel_amount * $ap->fuel_jeta_cost);
Log::info('Finance: Fuel cost, (fuel='.$fuel_amount.', cost='.$ap->fuel_jeta_cost.') D='
.$debit->getAmount());
$this->financeSvc->debitFromJournal(
$pirep->airline->journal,
$debit,
$pirep,
'Fuel Cost ('.$ap->fuel_jeta_cost.'/'.config('phpvms.internal_units.fuel').')',
'Fuel',
'fuel'
);
}
/**
* Calculate what the cost is for the operating an aircraft
* in this subfleet, as-per the block time
*
* @param Pirep $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
* @throws \Prettus\Validator\Exceptions\ValidatorException
*/
public function payExpensesForSubfleet(Pirep $pirep): void
{
$sf = $pirep->aircraft->subfleet;
// Haven't entered a cost
if (!filled($sf->cost_block_hour)) {
return;
}
// Convert to cost per-minute
$cost_per_min = round($sf->cost_block_hour / 60, 2);
// Time to use - use the block time if it's there, actual
// flight time if that hasn't been used
$block_time = $pirep->block_time;
if (!filled($block_time)) {
Log::info('Finance: No block time, using PIREP flight time');
$block_time = $pirep->flight_time;
}
$debit = Money::createFromAmount($cost_per_min * $block_time);
Log::info('Finance: Subfleet Block Hourly, D='.$debit->getAmount());
$this->financeSvc->debitFromJournal(
$pirep->airline->journal,
$debit,
$pirep,
'Subfleet '.$sf->type.': Block Time Cost',
'Subfleet '.$sf->type,
'subfleet'
);
}
/**
* Collect all of the expenses and apply those to the journal
*
* @param Pirep $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
*/
public function payExpensesForPirep(Pirep $pirep): void
{
$expenses = $this->expenseRepo->getAllForType(
ExpenseType::FLIGHT,
$pirep->airline_id
);
/*
* Go through the expenses and apply a mulitplier if present
*/
$expenses->map(function (Expense $expense, $i) use ($pirep) {
// Airport expenses are paid out separately
if ($expense->ref_model === Airport::class) {
return;
}
Log::info('Finance: PIREP: '.$pirep->id.', expense:', $expense->toArray());
// Check to see if there is a certain fleet or flight type set on this expense
// if there is and it doesn't match up the flight type for the PIREP, skip it
if ($expense->ref_model === Expense::class) {
if (is_array($expense->flight_type) && count($expense->flight_type) > 0) {
if (!in_array($pirep->flight_type, $expense->flight_type, true)) {
return;
}
}
}
// Get the transaction group name from the ref_model name
// This way it can be more dynamic and don't have to add special
// tables or specific expense calls to accomodate all of these
$klass = 'Expense';
if ($expense->ref_model) {
$ref = explode('\\', $expense->ref_model);
$klass = end($ref);
}
// Form the memo, with some specific ones depending on the group
if ($expense->ref_model === Subfleet::class
&& $expense->ref_model_id === $pirep->aircraft->subfleet->id
) {
$memo = "Subfleet Expense: {$expense->name} ({$pirep->aircraft->subfleet->name})";
$transaction_group = "Subfleet: {$expense->name} ({$pirep->aircraft->subfleet->name})";
} elseif ($expense->ref_model === Aircraft::class
&& $expense->ref_model_id === $pirep->aircraft->id
) {
$memo = "Aircraft Expense: {$expense->name} ({$pirep->aircraft->name})";
$transaction_group = "Aircraft: {$expense->name} "
."({$pirep->aircraft->name}-{$pirep->aircraft->registration})";
} else {
$memo = "Expense: {$expense->name}";
$transaction_group = "Expense: {$expense->name}";
}
$debit = Money::createFromAmount($expense->amount);
// If the expense is marked to charge it to a user (only applicable to Flight)
// then change the journal to the user's to debit there
$journal = $pirep->airline->journal;
if ($expense->charge_to_user) {
$journal = $pirep->user->journal;
}
$this->financeSvc->debitFromJournal(
$journal,
$debit,
$pirep,
$memo,
$transaction_group,
strtolower($klass)
);
});
}
/**
* Pay the airport-specific expenses for a PIREP
*
* @param \App\Models\Pirep $pirep
*/
public function payAirportExpensesForPirep(Pirep $pirep): void
{
$expenses = $this->expenseRepo->getAllForType(
ExpenseType::FLIGHT,
$pirep->airline_id,
Airport::class,
$pirep->arr_airport_id
);
/*
* Go through the expenses and apply a mulitplier if present
*/
$expenses->map(function (Expense $expense, $i) use ($pirep) {
Log::info('Finance: PIREP: '.$pirep->id.', airport expense:', $expense->toArray());
$memo = "Airport Expense: {$expense->name} ({$expense->ref_model_id})";
$transaction_group = "Airport: {$expense->ref_model_id}";
$debit = Money::createFromAmount($expense->amount);
// Charge to the airlines journal
$journal = $pirep->airline->journal;
$this->financeSvc->debitFromJournal(
$journal,
$debit,
$pirep,
$memo,
$transaction_group,
'airport'
);
});
}
/**
* Collect all of the expenses from the listeners and apply those to the journal
*
* @param Pirep $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
* @throws \Prettus\Validator\Exceptions\ValidatorException
*/
public function payExpensesEventsForPirep(Pirep $pirep): void
{
/**
* Throw an event and collect any expenses returned from it
*/
$gathered_expenses = event(new ExpensesEvent($pirep));
if (!\is_array($gathered_expenses)) {
return;
}
foreach ($gathered_expenses as $event_expense) {
if (!\is_array($event_expense)) {
continue;
}
foreach ($event_expense as $expense) {
// Make sure it's of type expense Model
if (!($expense instanceof Expense)) {
continue;
}
Log::info('Finance: Expense from listener, N="'
.$expense->name.'", A='.$expense->amount);
// If an airline_id is filled, then see if it matches
/* @noinspection NotOptimalIfConditionsInspection */
if (filled($expense->airline_id) && $expense->airline_id !== $pirep->airline_id) {
Log::info('Finance: Expense has an airline ID and it doesn\'t match, skipping');
continue;
}
$debit = Money::createFromAmount($expense->amount);
$this->financeSvc->debitFromJournal(
$pirep->airline->journal,
$debit,
$pirep,
'Expense: '.$expense->name,
$expense->transaction_group ?? 'Expenses',
'expense'
);
}
}
}
/**
* Collect and apply the ground handling cost
*
* @param Pirep $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
* @throws \Prettus\Validator\Exceptions\ValidatorException
*/
public function payGroundHandlingForPirep(Pirep $pirep): void
{
$ground_handling_cost = $this->getGroundHandlingCost($pirep);
Log::info('Finance: PIREP: '.$pirep->id.'; ground handling: '.$ground_handling_cost);
$this->financeSvc->debitFromJournal(
$pirep->airline->journal,
Money::createFromAmount($ground_handling_cost),
$pirep,
'Ground Handling',
'Ground Handling',
'ground_handling'
);
}
/**
* Figure out what the pilot pay is. Debit it from the airline journal
* But also reference the PIREP
*
* @param Pirep $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
* @throws \Prettus\Validator\Exceptions\ValidatorException
*/
public function payPilotForPirep(Pirep $pirep): void
{
$pilot_pay = $this->getPilotPay($pirep);
if ($pirep->flight && !empty($pirep->flight->pilot_pay)) {
$memo = 'Pilot fixed payment for flight: '.$pirep->flight->pilot_pay;
Log::info('Finance: PIREP: '.$pirep->id
.'; pilot pay: fixed for flight='.$pirep->flight->pilot_pay.', total: '.$pilot_pay);
} else {
$pilot_pay_rate = $this->getPilotPayRateForPirep($pirep);
$memo = 'Pilot Payment @ '.$pilot_pay_rate;
Log::info('Finance: PIREP: '.$pirep->id
.'; pilot pay: '.$pilot_pay_rate.', total: '.$pilot_pay);
}
$this->financeSvc->debitFromJournal(
$pirep->airline->journal,
$pilot_pay,
$pirep,
$memo,
'Pilot Pay',
'pilot_pay'
);
$this->financeSvc->creditToJournal(
$pirep->user->journal,
$pilot_pay,
$pirep,
$memo,
'Pilot Pay',
'pilot_pay'
);
}
/**
* Return all of the fares for the PIREP. Reconcile the list;
* Get the fares that have been filled out for the PIREP, and
* then get the fares for the flight and subfleet. Then merge
* them together, and return the final list of:
* count = number of pax
* price = how much each pax unit paid
* capacity = max number of pax units
*
* If count > capacity, count will be adjusted to capacity
*
* @param $pirep
*
* @return \Illuminate\Support\Collection
*/
public function getReconciledFaresForPirep($pirep)
{
// Collect all of the fares and prices
$flight_fares = $this->fareSvc->getForPirep($pirep);
Log::info('Finance: PIREP: '.$pirep->id.', flight fares: ', $flight_fares->toArray());
$all_fares = $this->fareSvc->getAllFares($pirep->flight, $pirep->aircraft->subfleet);
$fares = $all_fares->map(function ($fare, $i) use ($flight_fares, $pirep) {
$fare_count = $flight_fares
->where('fare_id', $fare->id)
->first();
if ($fare_count) {
Log::info('Finance: PIREP: '.$pirep->id.', fare count: '.$fare_count);
// If the count is greater than capacity, then just set it
// to the maximum amount
if ($fare_count->count > $fare->capacity) {
$fare->count = $fare->capacity;
} else {
$fare->count = $fare_count->count;
}
} else {
Log::info('Finance: PIREP: '.$pirep->id.', no fare count found', $fare->toArray());
}
return $fare;
});
return $fares;
}
/**
* Return the costs for the ground handling, with the multiplier
* being applied from the subfleet
*
* @param Pirep $pirep
*
* @return float|null
*/
public function getGroundHandlingCost(Pirep $pirep)
{
if (filled($pirep->aircraft->subfleet->ground_handling_multiplier)) {
// force into percent mode
$multiplier = $pirep->aircraft->subfleet->ground_handling_multiplier.'%';
return Math::applyAmountOrPercent(
$pirep->arr_airport->ground_handling_cost,
$multiplier
);
}
return $pirep->arr_airport->ground_handling_cost;
}
/**
* Return the pilot's hourly pay for the given PIREP
*
* @param Pirep $pirep
*
* @throws \InvalidArgumentException
*
* @return float
*/
public function getPilotPayRateForPirep(Pirep $pirep)
{
// Get the base rate for the rank
$rank = $pirep->user->rank;
$subfleet_id = $pirep->aircraft->subfleet_id;
// find the right subfleet
$override_rate = $rank->subfleets()
->where('subfleet_id', $subfleet_id)
->first();
if ($override_rate) {
$override_rate = $override_rate->pivot;
}
if ($pirep->source === PirepSource::ACARS) {
Log::debug('Source is ACARS');
$base_rate = $rank->acars_base_pay_rate;
if ($override_rate) {
$override_rate = $override_rate->acars_pay;
}
} else {
Log::debug('Source is Manual');
$base_rate = $rank->manual_base_pay_rate;
if ($override_rate) {
$override_rate = $override_rate->manual_pay;
}
}
Log::debug('pilot pay: base rate='.$base_rate.', override='.$override_rate);
return Math::applyAmountOrPercent(
$base_rate,
$override_rate
);
}
/**
* Get the user's payment amount for a PIREP
*
* @param Pirep $pirep
*
* @throws \UnexpectedValueException
* @throws \InvalidArgumentException
*
* @return Money
*/
public function getPilotPay(Pirep $pirep)
{
// If there is a fixed price for this flight, return that amount
$flight = $pirep->flight;
if ($flight && !empty($flight->pilot_pay)) {
return new Money(Money::convertToSubunit($flight->pilot_pay));
}
// Divided by 60 to get the rate per minute
$pilot_rate = $this->getPilotPayRateForPirep($pirep) / 60;
$payment = round($pirep->flight_time * $pilot_rate, 2);
Log::info('Pilot Payment: rate='.$pilot_rate);
$payment = Money::convertToSubunit($payment);
return new Money($payment);
}
}