ultimatepos/Modules/Accounting/Services/LocationDefaultMappingService.php

309 lines
11 KiB
PHP

<?php
namespace Modules\Accounting\Services;
use App\Account;
use App\BusinessLocation;
use App\ExpenseCategory;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Modules\Accounting\Entities\AccountingAccount;
class LocationDefaultMappingService
{
public const CORE_SCENARIOS = [
'sale' => [
'label_key' => 'sale.sale',
'payment_account' => ['Sales', 'Sales of Product Income', 'Uncategorised Income'],
'deposit_to' => ['Accounts Receivable (A/R)'],
'payment_primary_type' => 'income',
'deposit_primary_type' => 'asset',
],
'sell_payment' => [
'label_key' => 'accounting::lang.sales_payments',
'payment_account' => ['Accounts Receivable (A/R)'],
'deposit_to' => ['Cash and cash equivalents', 'Undeposited Funds', 'Bank'],
'payment_primary_type' => 'asset',
'deposit_primary_type' => 'asset',
],
'purchases' => [
'label_key' => 'purchase.purchases',
'payment_account' => ['Accounts Payable (A/P)'],
'deposit_to' => ['Inventory Asset', 'Inventory', 'Uncategorised Asset'],
'payment_primary_type' => 'liability',
'deposit_primary_type' => 'asset',
],
'purchase_payment' => [
'label_key' => 'accounting::lang.purchase_payments',
'payment_account' => ['Accounts Payable (A/P)'],
'deposit_to' => ['Cash and cash equivalents', 'Undeposited Funds', 'Bank'],
'payment_primary_type' => 'liability',
'deposit_primary_type' => 'asset',
],
'expense' => [
'label_key' => 'accounting::lang.expenses',
'payment_account' => ['Uncategorised Expense', 'Utilities', 'Wage expenses'],
'deposit_to' => ['Cash and cash equivalents', 'Accounts Payable (A/P)', 'Undeposited Funds'],
'payment_primary_type' => 'expenses',
'deposit_primary_type' => 'asset',
],
];
public function assess(int $businessId): array
{
$locations = BusinessLocation::where('business_id', $businessId)->get(['id', 'name', 'accounting_default_map']);
$expenseCategories = ExpenseCategory::where('business_id', $businessId)->get(['id', 'name']);
$locationRows = [];
$summary = [];
foreach ($locations as $location) {
$map = $this->decodeMap($location->accounting_default_map);
$scenarios = [];
foreach ($this->scenarioDefinitions($expenseCategories) as $key => $definition) {
$missing = $this->missingFields($map, $key);
$complete = empty($missing);
$scenarios[] = [
'key' => $key,
'label' => __($definition['label_key']),
'complete' => $complete,
'missing' => $missing,
];
if (! isset($summary[$key])) {
$summary[$key] = ['total' => 0, 'complete' => 0];
}
$summary[$key]['total']++;
if ($complete) {
$summary[$key]['complete']++;
}
}
$missingCount = collect($scenarios)->where('complete', false)->count();
$locationRows[] = [
'location_id' => (int) $location->id,
'location_name' => (string) $location->name,
'scenarios' => $scenarios,
'missing_count' => $missingCount,
'is_complete' => $missingCount === 0,
];
}
$completeLocations = collect($locationRows)->where('is_complete', true)->count();
return [
'total_locations' => count($locationRows),
'complete_locations' => $completeLocations,
'locations' => $locationRows,
'summary' => $summary,
];
}
public function sync(int $businessId, bool $overwrite = false): array
{
$accounts = $this->loadAccounts($businessId);
$locations = BusinessLocation::where('business_id', $businessId)->get(['id', 'name', 'accounting_default_map']);
$expenseCategories = ExpenseCategory::where('business_id', $businessId)->get(['id', 'name']);
$updatedLocations = 0;
$filledFields = 0;
$skippedFields = 0;
$details = [];
foreach ($locations as $location) {
$map = $this->decodeMap($location->accounting_default_map);
$locationChanged = false;
foreach ($this->scenarioDefinitions($expenseCategories) as $key => $definition) {
foreach (['payment_account', 'deposit_to'] as $field) {
$current = data_get($map, "{$key}.{$field}");
if (! $overwrite && ! empty($current)) {
$skippedFields++;
continue;
}
$resolved = $this->resolveAccountId(
$accounts,
$definition[$field] ?? [],
$definition["{$field}_primary_type"] ?? null
);
if (! $resolved) {
$skippedFields++;
continue;
}
if ((int) $current !== (int) $resolved) {
data_set($map, "{$key}.{$field}", $resolved);
$filledFields++;
$locationChanged = true;
$details[] = [
'location_id' => (int) $location->id,
'location_name' => (string) $location->name,
'scenario' => $key,
'field' => $field,
'account_id' => $resolved,
];
} else {
$skippedFields++;
}
}
}
if ($locationChanged) {
BusinessLocation::where('id', $location->id)->update([
'accounting_default_map' => json_encode($map),
]);
$updatedLocations++;
}
}
$paymentLinks = $this->syncPaymentAccountGlLinks($businessId, $accounts, $overwrite);
return [
'updated_locations' => $updatedLocations,
'filled_fields' => $filledFields,
'skipped_fields' => $skippedFields,
'payment_accounts_linked' => $paymentLinks['linked'],
'details' => $details,
];
}
public function syncPaymentAccountGlLinks(int $businessId, ?Collection $accounts = null, bool $overwrite = false): array
{
$accounts = $accounts ?? $this->loadAccounts($businessId);
$bankGlIds = $accounts
->filter(fn ($account) => $this->matchesPreferredName($account->name, ['Cash and cash equivalents', 'Undeposited Funds', 'Bank']))
->pluck('id')
->all();
if (empty($bankGlIds)) {
return ['linked' => 0];
}
$defaultBankGlId = (int) $bankGlIds[0];
$linked = 0;
$paymentAccounts = Account::where('business_id', $businessId)
->where('is_closed', 0)
->get(['id', 'name', 'accounting_account_id']);
foreach ($paymentAccounts as $paymentAccount) {
if (! $overwrite && ! empty($paymentAccount->accounting_account_id)) {
continue;
}
$matchedGl = $accounts->first(function ($gl) use ($paymentAccount) {
return $this->namesAreSimilar($paymentAccount->name, $gl->name);
});
$glId = $matchedGl?->id ?? $defaultBankGlId;
DB::table('accounts')
->where('id', $paymentAccount->id)
->where('business_id', $businessId)
->update(['accounting_account_id' => $glId]);
$linked++;
}
return ['linked' => $linked];
}
protected function scenarioDefinitions(Collection $expenseCategories): array
{
$definitions = self::CORE_SCENARIOS;
foreach ($expenseCategories as $category) {
$key = 'expense_'.$category->id;
$definitions[$key] = [
'label_key' => 'accounting::lang.expenses',
'label_suffix' => $category->name,
'payment_account' => ['Uncategorised Expense', 'Utilities', 'Wage expenses'],
'deposit_to' => ['Cash and cash equivalents', 'Accounts Payable (A/P)', 'Undeposited Funds'],
'payment_primary_type' => 'expenses',
'deposit_primary_type' => 'asset',
];
}
return $definitions;
}
protected function missingFields(array $map, string $scenarioKey): array
{
$missing = [];
foreach (['payment_account', 'deposit_to'] as $field) {
if (empty(data_get($map, "{$scenarioKey}.{$field}"))) {
$missing[] = $field;
}
}
return $missing;
}
protected function decodeMap(?string $raw): array
{
$decoded = json_decode((string) $raw, true);
return is_array($decoded) ? $decoded : [];
}
protected function loadAccounts(int $businessId): Collection
{
return AccountingAccount::query()
->where('business_id', $businessId)
->where('status', 'active')
->get(['id', 'name', 'account_primary_type']);
}
protected function resolveAccountId(Collection $accounts, array $preferredNames, ?string $primaryType): ?int
{
foreach ($preferredNames as $name) {
$match = $accounts->first(fn ($account) => strcasecmp($account->name, $name) === 0);
if ($match) {
return (int) $match->id;
}
}
foreach ($preferredNames as $name) {
$match = $accounts->first(fn ($account) => $this->matchesPreferredName($account->name, [$name]));
if ($match) {
return (int) $match->id;
}
}
if ($primaryType) {
$types = $primaryType === 'expenses' ? ['expenses', 'expense'] : [$primaryType];
$match = $accounts->first(fn ($account) => in_array($account->account_primary_type, $types, true));
if ($match) {
return (int) $match->id;
}
}
return null;
}
protected function matchesPreferredName(string $accountName, array $preferredNames): bool
{
$normalized = strtolower($accountName);
foreach ($preferredNames as $preferred) {
if (str_contains($normalized, strtolower($preferred))) {
return true;
}
}
return false;
}
protected function namesAreSimilar(string $left, string $right): bool
{
$left = strtolower(trim($left));
$right = strtolower(trim($right));
return $left === $right
|| str_contains($left, $right)
|| str_contains($right, $left);
}
}