initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View 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;
}
}