Complete adaptation from BukidBountyApp to Philippine barangay governance: - Barangay models: Resident, Household, HouseholdMember, Blotter, BlotterHearing, DocumentRequest, RequestPayment, RequestType, BarangayProject, BarangayBudget - Controllers: ResidentController, HouseholdController, BlotterController, BlotterHearingController, DocumentRequestController, RequestTypeController, ProjectController, BudgetController, QRPHController, AdminConsoleController, UserController, FileController, ChapterController, LoginController - Vue pages: Home, ManageResidents, ResidentProfile, ManageHouseholds, ManageBlotters, BlotterDetail, RequestDocument, ManageDocumentRequests, DocumentRequestDetail, ManageRequestTypes, ManageProjects, BudgetLedger, AdminConsole - Barangay roles: PunongBarangay, Kagawad, Secretary, Treasurer, SK, Tanod, BHW, Staff, Resident - UserPermissions matrix rewritten with barangay-specific permission mappings - VueRouteMap replaced with barangay SPA routes - UserActions enum references corrected across all controllers - Removed all market/cooperative/POS/subscription code and models
296 lines
9.6 KiB
PHP
296 lines
9.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\SystemSetting;
|
|
use Hypervel\Support\Facades\Config;
|
|
use Hypervel\Support\Str;
|
|
|
|
/**
|
|
* Accounting theme registry.
|
|
*
|
|
* Themes are defined in config/accounting/themes.php and identified by the
|
|
* `accounting_theme` system setting. Theme identity travels with the data:
|
|
* accounts seeded from a theme are stamped with theme_key + theme_account_code
|
|
* so we can branch on the active theme and detect drift later, even after
|
|
* users edit the chart through admin CRUD.
|
|
*/
|
|
class AccountingTheme
|
|
{
|
|
public const DEFAULT_KEY = 'blank';
|
|
|
|
public const FALLBACK_KEY = 'blank';
|
|
|
|
/**
|
|
* Active theme key for this deployment.
|
|
*/
|
|
public static function current(): string
|
|
{
|
|
$key = SystemSetting::getValue('accounting_theme', self::DEFAULT_KEY);
|
|
|
|
if (!\is_string($key) || $key === '' || !\array_key_exists($key, self::all())) {
|
|
return self::FALLBACK_KEY;
|
|
}
|
|
|
|
return $key;
|
|
}
|
|
|
|
/**
|
|
* Whether the active theme matches the given key.
|
|
*/
|
|
public static function is(string $key): bool
|
|
{
|
|
return self::current() === $key;
|
|
}
|
|
|
|
/**
|
|
* All registered theme definitions, keyed by theme key.
|
|
*
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
public static function all(): array
|
|
{
|
|
$themes = Config::get('accounting.themes', []);
|
|
return \is_array($themes) ? $themes : [];
|
|
}
|
|
|
|
/**
|
|
* The definition for a single theme (or the active theme when no key given).
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function definition(?string $key = null): ?array
|
|
{
|
|
$key = $key ?? self::current();
|
|
$themes = self::all();
|
|
return $themes[$key] ?? null;
|
|
}
|
|
|
|
/**
|
|
* The starter Chart of Accounts hierarchy for a theme.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public static function tree(?string $key = null): array
|
|
{
|
|
$definition = self::definition($key);
|
|
$tree = $definition['tree'] ?? [];
|
|
return \is_array($tree) ? $tree : [];
|
|
}
|
|
|
|
/**
|
|
* Convert an account name to a stable code for the given theme.
|
|
* Used so re-applying a theme can match existing seeded accounts even if
|
|
* a user has renamed them.
|
|
*/
|
|
public static function accountCode(string $name): string
|
|
{
|
|
$slug = strtolower(trim($name));
|
|
$slug = preg_replace('/[^a-z0-9]+/', '_', $slug) ?? '';
|
|
return trim($slug, '_');
|
|
}
|
|
|
|
/**
|
|
* Flatten a theme tree into a list of definitions keyed by code.
|
|
*
|
|
* Each entry: ['code', 'name', 'type', 'default_flow', 'parent_code', 'description'].
|
|
*
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
public static function flatten(?string $key = null): array
|
|
{
|
|
$flat = [];
|
|
self::flattenWalk(self::tree($key), null, $flat);
|
|
return $flat;
|
|
}
|
|
|
|
private static function flattenWalk(array $nodes, ?string $parentCode, array &$flat): void
|
|
{
|
|
foreach ($nodes as $node) {
|
|
$name = (string) ($node['name'] ?? '');
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$code = self::accountCode($name);
|
|
$type = strtoupper((string) ($node['type'] ?? ''));
|
|
$flat[$code] = [
|
|
'code' => $code,
|
|
'name' => $name,
|
|
'type' => $type,
|
|
'default_flow' => $node['default_flow'] ?? (($type === 'REVENUE' || $type === 'LIABILITY') ? 'INCOME' : 'EXPENSE'),
|
|
'parent_code' => $parentCode,
|
|
'description' => $node['description'] ?? null,
|
|
];
|
|
if (!empty($node['children']) && \is_array($node['children'])) {
|
|
self::flattenWalk($node['children'], $code, $flat);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare the active theme against the DB. Pure read — never mutates.
|
|
*
|
|
* Returns:
|
|
* - missing: codes in the theme but absent from DB (need apply)
|
|
* - present: codes in both, name matches
|
|
* - renamed: codes in both but name differs in DB (user-edited)
|
|
* - user_added: account ids/names with theme_key NULL (custom additions)
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function drift(?string $key = null): array
|
|
{
|
|
$key = $key ?? self::current();
|
|
$themeAccounts = self::flatten($key);
|
|
|
|
$rows = Account::where('theme_key', $key)
|
|
->get(['id', 'name', 'theme_account_code', 'is_active', 'parent_id'])
|
|
->keyBy('theme_account_code');
|
|
|
|
$missing = [];
|
|
$present = [];
|
|
$renamed = [];
|
|
foreach ($themeAccounts as $code => $def) {
|
|
$row = $rows[$code] ?? null;
|
|
if (!$row) {
|
|
$missing[] = ['code' => $code, 'name' => $def['name']];
|
|
continue;
|
|
}
|
|
if ($row->name !== $def['name']) {
|
|
$renamed[] = ['code' => $code, 'expected' => $def['name'], 'current' => $row->name, 'id' => $row->id];
|
|
} else {
|
|
$present[] = ['code' => $code, 'name' => $def['name'], 'id' => $row->id];
|
|
}
|
|
}
|
|
|
|
$userAdded = Account::whereNull('theme_key')
|
|
->orderBy('id')
|
|
->get(['id', 'name', 'parent_id'])
|
|
->map(fn($r) => ['id' => $r->id, 'name' => $r->name, 'parent_id' => $r->parent_id])
|
|
->all();
|
|
|
|
return [
|
|
'theme_key' => $key,
|
|
'missing' => $missing,
|
|
'present' => $present,
|
|
'renamed' => $renamed,
|
|
'user_added' => $userAdded,
|
|
'totals' => [
|
|
'expected' => \count($themeAccounts),
|
|
'missing' => \count($missing),
|
|
'present' => \count($present),
|
|
'renamed' => \count($renamed),
|
|
'user_added' => \count($userAdded),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Idempotently apply the active (or specified) theme to the DB.
|
|
*
|
|
* Never deletes, never renames user edits. For each theme node:
|
|
* - if a row matches by (theme_key, theme_account_code), stamp/refresh metadata
|
|
* - else if a row matches by (parent_id, name), back-stamp it
|
|
* - else create a new row
|
|
*
|
|
* @return array{created:int, stamped:int, skipped:int, theme_key:string}
|
|
*/
|
|
public static function apply(?string $key = null): array
|
|
{
|
|
$key = $key ?? self::current();
|
|
$tree = self::tree($key);
|
|
|
|
$stats = ['created' => 0, 'stamped' => 0, 'skipped' => 0, 'theme_key' => $key];
|
|
foreach ($tree as $node) {
|
|
self::applyNode($node, null, $key, $stats);
|
|
}
|
|
return $stats;
|
|
}
|
|
|
|
private static function applyNode(array $node, ?int $parentId, string $themeKey, array &$stats): void
|
|
{
|
|
$name = (string) ($node['name'] ?? '');
|
|
if ($name === '') {
|
|
return;
|
|
}
|
|
$code = self::accountCode($name);
|
|
$type = strtoupper((string) ($node['type'] ?? ''));
|
|
$defaultFlow = $node['default_flow']
|
|
?? (($type === 'REVENUE' || $type === 'LIABILITY') ? 'INCOME' : 'EXPENSE');
|
|
|
|
$account = Account::where('theme_key', $themeKey)
|
|
->where('theme_account_code', $code)
|
|
->where(function ($q) use ($parentId) {
|
|
if ($parentId === null) {
|
|
$q->whereNull('parent_id');
|
|
} else {
|
|
$q->where('parent_id', $parentId);
|
|
}
|
|
})
|
|
->first();
|
|
|
|
if (!$account) {
|
|
$account = Account::where('parent_id', $parentId)
|
|
->where('name', $name)
|
|
->first();
|
|
}
|
|
|
|
if ($account) {
|
|
$dirty = false;
|
|
if ($account->theme_key !== $themeKey) { $account->theme_key = $themeKey; $dirty = true; }
|
|
if ($account->theme_account_code !== $code) { $account->theme_account_code = $code; $dirty = true; }
|
|
if (empty($account->default_flow)) { $account->default_flow = $defaultFlow; $dirty = true; }
|
|
if (empty($account->description) && !empty($node['description'])) {
|
|
$account->description = $node['description'];
|
|
$dirty = true;
|
|
}
|
|
if ($dirty) {
|
|
$account->save();
|
|
$stats['stamped']++;
|
|
} else {
|
|
$stats['skipped']++;
|
|
}
|
|
} else {
|
|
$account = Account::create([
|
|
'hashkey' => Str::random(40),
|
|
'parent_id' => $parentId,
|
|
'type' => $type,
|
|
'default_flow' => $defaultFlow,
|
|
'name' => $name,
|
|
'description' => $node['description'] ?? null,
|
|
'theme_key' => $themeKey,
|
|
'theme_account_code' => $code,
|
|
'is_active' => true,
|
|
]);
|
|
$stats['created']++;
|
|
}
|
|
|
|
if (!empty($node['children']) && \is_array($node['children'])) {
|
|
foreach ($node['children'] as $child) {
|
|
self::applyNode($child, $account->id, $themeKey, $stats);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lightweight summary of all themes for admin UI dropdowns.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public static function options(): array
|
|
{
|
|
$options = [];
|
|
foreach (self::all() as $key => $def) {
|
|
$options[] = [
|
|
'key' => $key,
|
|
'label' => $def['label'] ?? $key,
|
|
'description' => $def['description'] ?? null,
|
|
'version' => $def['version'] ?? 1,
|
|
];
|
|
}
|
|
return $options;
|
|
}
|
|
}
|