initial: bootstrap from BukidBountyApp base
This commit is contained in:
296
app/Support/AccountingTheme.php
Normal file
296
app/Support/AccountingTheme.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Accounting\Account;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user