309 lines
11 KiB
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);
|
|
}
|
|
}
|