CaiWuXiTong/app/Reports/ProfitLoss.php
WuYongJie 9595267ee7
Some checks failed
Tests / PHP 8.1 (push) Has been cancelled
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
chore: 导入 Akaunting 初始源码与团队协作规则
2026-07-20 18:20:25 +08:00

317 lines
11 KiB
PHP

<?php
namespace App\Reports;
use App\Abstracts\Report;
use App\Models\Banking\Transaction;
use App\Models\Document\Document;
use App\Models\Document\DocumentItem;
use App\Models\Setting\Category;
use App\Utilities\Date;
use App\Utilities\Recurring;
use Illuminate\Database\Eloquent\Builder;
class ProfitLoss extends Report
{
public $default_name = 'reports.profit_loss';
public $category = 'general.accounting';
public $icon = 'favorite_border';
public $type = 'detail';
public $chart = false;
public $gross_profit = [];
public $net_profit = [];
public function setViews()
{
parent::setViews();
$this->views['detail'] = 'reports.profit_loss.detail';
$this->views['detail.content.header'] = 'reports.profit_loss.content.header';
$this->views['detail.content.footer'] = 'reports.profit_loss.content.footer';
$this->views['detail.table.header'] = 'reports.profit_loss.table.header';
$this->views['detail.table.row'] = 'reports.profit_loss.table.row';
$this->views['detail.table.footer'] = 'reports.profit_loss.table.footer';
}
public function setTables()
{
$this->tables = [
Category::INCOME_TYPE => trans_choice('general.incomes', 1),
Category::DIRECT_COST_TYPE => trans_choice('general.cogs', 2),
Category::EXPENSE_TYPE => trans_choice('general.expenses', 2),
];
}
public function setData()
{
switch ($this->getBasis()) {
case 'cash':
$incomes_query = $this->getTransactionQuery(Transaction::INCOME_TYPE);
$expenses_query = $this->getTransactionQuery(Transaction::EXPENSE_TYPE);
// Incomes
$incomes = $incomes_query->get();
$this->setTotals($incomes, 'paid_at', false, Category::INCOME_TYPE, false);
// Direct Costs: Expenses
$direct_costs_expenses_query = clone $expenses_query; // To avoid affecting the original query used for non-Direct Costs expenses
$direct_costs_expenses = $direct_costs_expenses_query->whereHas('category', fn (Builder $q) => $q->directCost())->get();
$this->setTotals($direct_costs_expenses, 'paid_at', false, Category::DIRECT_COST_TYPE, false);
// Expenses
$expenses = $expenses_query->whereDoesntHave('category', fn (Builder $q) => $q->directCost())->get();
$this->setTotals($expenses, 'paid_at', false, Category::EXPENSE_TYPE, false);
break;
default:
$incomes_query = $this->getTransactionQuery(Transaction::INCOME_TYPE);
$expenses_query = $this->getTransactionQuery(Transaction::EXPENSE_TYPE);
$invoices_query = $this->getDocumentQuery(Document::INVOICE_TYPE);
$bills_query = $this->getDocumentQuery(Document::BILL_TYPE);
// Invoices
$invoices = $invoices_query->get();
Recurring::reflect($invoices, 'issued_at');
$this->setTotals(
items: $this->flattenDocumentItems($invoices),
date_field: 'issued_at',
check_type: false,
table: Category::INCOME_TYPE,
with_tax: false,
);
// Incomes
$incomes = $incomes_query->isNotDocument()->get();
Recurring::reflect($incomes, 'paid_at');
$this->setTotals($incomes, 'paid_at', false, Category::INCOME_TYPE, false);
// Bills: Direct Costs & Expenses
$bills = $bills_query->get();
Recurring::reflect($bills, 'issued_at');
$flat_bill_items = $this->flattenDocumentItems($bills);
$this->setTotals(
items: $flat_bill_items->filter(fn (DocumentItem $i) => $i->category?->isDirectCostCategory()),
date_field: 'issued_at',
check_type: false,
table: Category::DIRECT_COST_TYPE,
with_tax: false,
);
$this->setTotals(
items: $flat_bill_items->filter(fn (DocumentItem $i) => ! $i->category?->isDirectCostCategory()),
date_field: 'issued_at',
check_type: false,
table: Category::EXPENSE_TYPE,
with_tax: false,
);
// Direct Costs: Expenses
$direct_costs_expenses_query = clone $expenses_query; // To avoid affecting the original query used for non-Direct Costs expenses
$direct_costs_expenses = $direct_costs_expenses_query->whereHas('category', fn (Builder $q) => $q->directCost())->get();
Recurring::reflect($direct_costs_expenses, 'paid_at');
$this->setTotals($direct_costs_expenses, 'paid_at', false, Category::DIRECT_COST_TYPE, false);
// Expenses
$expenses = $expenses_query->whereDoesntHave('category', fn (Builder $q) => $q->directCost())->get();
Recurring::reflect($expenses, 'paid_at');
$this->setTotals($expenses, 'paid_at', false, Category::EXPENSE_TYPE, false);
break;
}
$this->setGrossProfit();
$this->setNetProfit();
}
public function getTransactionQuery(string $type): Builder
{
return $this->applyFilters(
model: Transaction::with('recurring')->$type()->isNotTransfer(),
args: ['date_field' => 'paid_at', 'model_type' => $type],
);
}
public function getDocumentQuery(string $type): Builder
{
return $this->applyFilters(
model: Document::with('recurring', 'totals', 'transactions', 'items.category')->$type()->accrued(),
args: ['date_field' => 'issued_at', 'model_type' => $type],
);
}
public function setGrossProfit(): void
{
foreach ($this->dates as $date) {
$income = $this->footer_totals[Category::INCOME_TYPE][$date] ?? 0;
$direct_costs = $this->footer_totals[Category::DIRECT_COST_TYPE][$date] ?? 0;
$this->gross_profit[$date] = $income - $direct_costs;
}
}
public function setNetProfit(): void
{
foreach ($this->footer_totals as $table => $dates) {
if (! in_array($table, [Category::INCOME_TYPE, Category::EXPENSE_TYPE])) {
continue;
}
foreach ($dates as $date => $total) {
if (! isset($this->net_profit[$date])) {
$this->net_profit[$date] = 0;
}
if ($table == Category::INCOME_TYPE) {
$this->net_profit[$date] += $total;
continue;
}
$this->net_profit[$date] -= $total;
}
}
}
public function array(): array
{
$data = parent::array();
$net_profit = $this->net_profit;
$gross_profit = $this->gross_profit;
if ($this->has_money) {
$net_profit = array_map(fn ($value) => money($value)->format(), $net_profit);
$gross_profit = array_map(fn ($value) => money($value)->format(), $gross_profit);
}
$data['gross_profit'] = $gross_profit;
$data['net_profit'] = $net_profit;
return $data;
}
public function getFields(): array
{
return [
$this->getGroupField(),
$this->getPeriodField(),
$this->getBasisField(),
$this->getPercentageField(),
];
}
public function getPercentageField(): array
{
return [
'type' => 'select',
'name' => 'show_percentage',
'title' => trans('reports.percentage_of_income'),
'icon' => 'percent',
'values' => [
'yes' => trans('general.yes'),
'no' => trans('general.no'),
],
'selected' => 'no',
'attributes' => [
'required' => 'required',
],
];
}
public function showPercentage(): bool
{
return $this->getSearchStringValue('show_percentage', $this->getSetting('show_percentage')) === 'yes';
}
public function getPercentageOfIncome(string $date, float|int $cell_value): ?string
{
if (! $this->showPercentage()) {
return null;
}
$income_total = $this->footer_totals['income'][$date] ?? 0;
if ($income_total == 0) {
return null;
}
$pct = round($cell_value / $income_total * 100, 1);
return setting('localisation.percent_position') == 'after'
? $pct . '%'
: '%' . $pct;
}
public function getDrillDownUrl(string $date, int|string $id): string
{
[$date_start, $date_end] = $this->getDateRangeForDrillDown($date);
$group = $this->getGroup();
// category_id:519 paid_at>=2026-03-01 paid_at<=2026-03-29
$search = implode(
separator: ' ',
array: [
"{$group}_id:{$id}",
"paid_at>={$date_start}",
"paid_at<={$date_end}",
],
);
return route('transactions.index') . '?list_records=all&search=' . $search;
}
private function getDateRangeForDrillDown(string $date): array
{
switch ($this->getPeriod()) {
case 'yearly':
$range = [
trim($date) . '-01-01',
trim($date) . '-12-31',
];
break;
case 'quarterly':
[$d_start, $d_end] = array_map('trim', explode(' - ', $date, 2));
$range = [
Date::createFromFormat('M Y', $d_start)->startOfMonth()->format('Y-m-d'),
Date::createFromFormat('M Y', $d_end)->endOfMonth()->format('Y-m-d'),
];
break;
case 'weekly':
[$d_start, $d_end] = array_map('trim', explode(' - ', $date, 2));
$range = [
Date::createFromFormat('d M Y', $d_start)->startOfDay()->format('Y-m-d'),
Date::createFromFormat('d M Y', $d_end)->endOfDay()->format('Y-m-d'),
];
break;
default: // monthly
$range = [
Date::createFromFormat('M Y', $date)->startOfMonth()->format('Y-m-d'),
Date::createFromFormat('M Y', $date)->endOfMonth()->format('Y-m-d'),
];
break;
}
// Clamp to report's start_date/end_date if present
$report_start = request('start_date');
$report_end = request('end_date');
if ($report_start && $report_end) {
$range[0] = max($range[0], $report_start);
$range[1] = min($range[1], $report_end);
}
return $range;
}
}