From 83b289818932fe28a71f1ee9117d8b017e3d52d3 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Mon, 26 Oct 2026 14:08:36 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E5=8F=B0=E8=B4=A6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E5=A2=9E=E5=85=AC=E5=8F=B8=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=20(#590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Business/FinanceLedger.php | 67 ++++++ docs/财务台账开发说明.md | 1 + .../Services/FinanceLedgerAccountCreator.php | 139 ++++++++++++ .../Services/FinanceLedgerCalculator.php | 2 +- .../View/FinanceLedgerPageData.php | 20 ++ .../pages/financial-ledger/ledger.blade.php | 207 +++++++++++++++++- routes/admin.php | 4 + .../FinanceLedgerAccountStatusTest.php | 97 +++++++- .../FinanceLedgerCalculatorTest.php | 36 +++ 9 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index aa008265..76b08226 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; +use Modules\EcommerceFinance\Services\FinanceLedgerAccountCreator; use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; @@ -36,6 +37,72 @@ class FinanceLedger extends Controller return $template->download(); } + public function storeAccount(Request $request, FinanceLedgerAccountCreator $creator): JsonResponse + { + abort_unless( + FinanceLedgerAccess::canEditAccountStatus(user()), + 403, + '只有财务台账管理员可以新增公司或账户。', + ); + + $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); + $data = $request->validate([ + 'company_name' => ['required', 'string', 'max:100'], + 'account_short' => ['required', 'string', 'max:100'], + 'account_type' => ['nullable', 'string', 'max:50'], + 'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))], + 'region' => ['nullable', 'string', 'max:50'], + 'account_no' => ['nullable', 'string', 'max:50'], + 'bank_name' => ['nullable', 'string', 'max:200'], + 'bank_city_branch' => ['nullable', 'string', 'max:100'], + 'company_plan' => ['nullable', 'string', 'max:50'], + 'account_should_keep' => [ + 'nullable', + 'string', + Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS), + ], + 'opening_date' => [ + 'required', + 'date_format:Y-m-d', + 'after_or_equal:' . $openingDate, + 'before_or_equal:' . now()->toDateString(), + ], + 'opening_balance' => ['nullable', 'numeric'], + ]); + + $accountType = trim((string) ($data['account_type'] ?? '')); + if ($accountType !== '') { + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $allowedTypes = FinanceLedgerAccountType::options( + $connection->table('fin_accounts') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(), + $connection->table('fin_account_type_overrides') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(), + ); + if (! in_array($accountType, $allowedTypes, true)) { + throw ValidationException::withMessages([ + 'account_type' => '请选择现有账户类型。', + ]); + } + } + + $result = $creator->create($data); + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $message = $result['company_created'] + ? '已新增公司并建立首个账户。' + : '已新增账户并关联现有公司。'; + + return response()->json([ + 'ok' => true, + 'message' => $message, + 'account' => $result, + 'total_accounts' => $connection->table('fin_accounts')->count(), + 'total_companies' => $connection->table('fin_companies')->count(), + ], 201, ['Cache-Control' => 'private, no-store']); + } + public function syncShopData(FinanceLedgerShopDataSynchronizer $synchronizer) { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以同步店铺余额和提现。'); diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 0e6d6ed5..7d12dcea 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -64,6 +64,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ - 路由:`/workspace/financial-operations/ledger`(旧地址会自动跳转) - 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源 - 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明 +- 账户总表编辑模式支持直接新增公司和首个账户:公司不存在时同步写入公司台账,账户简称必须唯一,可同时登记期初日期与期初余额 - 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读 - 调度:`finance-ledger:sync-rpt --days=7 --merge` 每天 02:30(Asia/Shanghai) diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php new file mode 100644 index 00000000..64fa93fc --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php @@ -0,0 +1,139 @@ +text($data['company_name'] ?? ''); + $accountShort = $this->text($data['account_short'] ?? ''); + $accountNo = $this->text($data['account_no'] ?? ''); + $accountType = $this->text($data['account_type'] ?? ''); + $companyPlan = $this->text($data['company_plan'] ?? ''); + $openingDate = (string) ($data['opening_date'] ?? now()->toDateString()); + $openingBalance = round((float) ($data['opening_balance'] ?? 0), 2); + $status = FinanceLedgerAccountStatus::normalize((string) ($data['status'] ?? '')); + $accountShouldKeep = FinanceLedgerAccountShouldKeep::normalize( + (string) ($data['account_should_keep'] ?? ''), + ); + $accountLast4 = FinanceLedgerAccountType::last4($accountType, $accountNo); + $normal = $status === FinanceLedgerAccountStatus::NORMAL; + $now = now(); + + return $connection->transaction(function () use ( + $connection, + $companyName, + $accountShort, + $accountNo, + $accountType, + $companyPlan, + $openingDate, + $openingBalance, + $status, + $accountShouldKeep, + $accountLast4, + $normal, + $data, + $now, + ): array { + if ($connection->table('fin_accounts')->where('account_short', $accountShort)->exists()) { + throw ValidationException::withMessages([ + 'account_short' => '账户简称已存在,请改用其他简称或直接编辑现有账户。', + ]); + } + + $company = $connection->table('fin_companies')->where('name', $companyName)->first(['id']); + $companyCreated = $company === null; + if ($companyCreated) { + $connection->table('fin_companies')->insert([ + 'name' => $companyName, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $accountId = $connection->table('fin_accounts')->insertGetId([ + 'company_name' => $companyName, + 'account_short' => $accountShort, + 'region' => $this->nullableText($data['region'] ?? null), + 'account_no' => $accountNo !== '' ? $accountNo : null, + 'bank_name' => $this->nullableText($data['bank_name'] ?? null), + 'bank_city_branch' => $this->nullableText($data['bank_city_branch'] ?? null), + 'account_type' => $accountType !== '' ? $accountType : null, + 'company_plan' => $companyPlan !== '' ? $companyPlan : null, + 'account_should_keep' => $accountShouldKeep !== '' ? $accountShouldKeep : null, + 'account_last4' => $accountLast4, + 'current_balance' => $openingBalance, + 'blocked_amount' => $normal ? 0 : $openingBalance, + 'daily_available' => $normal ? $openingBalance : 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $connection->table('fin_account_opening')->insert([ + 'account_short' => $accountShort, + 'opening_date' => $openingDate, + 'opening_balance' => $openingBalance, + 'remark' => '新增账户期初', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $connection->table('fin_daily_flows')->insert([ + 'flow_date' => $openingDate, + 'company_name' => $companyName, + 'account_short' => $accountShort, + 'opening_balance' => $openingBalance, + 'inflow' => 0, + 'outflow' => 0, + 'closing_balance' => $openingBalance, + 'remark' => '新增账户期初', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if (! $normal) { + $connection->table('fin_account_statuses')->insert([ + 'account_short' => $accountShort, + 'status' => $status, + 'updated_by' => (string) user()->name, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + return [ + 'id' => (int) $accountId, + 'company_name' => $companyName, + 'account_short' => $accountShort, + 'company_created' => $companyCreated, + 'status' => $status, + 'opening_date' => $openingDate, + 'opening_balance' => $openingBalance, + ]; + }); + } + + private function text(mixed $value): string + { + return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value); + } + + private function nullableText(mixed $value): ?string + { + $text = $this->text($value); + + return $text !== '' ? $text : null; + } +} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php index a3838009..c540bd27 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php @@ -24,8 +24,8 @@ class FinanceLedgerCalculator $now = now(); $opening = $connection->table('fin_account_opening') - ->where('opening_date', '<=', $openingDate) ->orderBy('opening_date') + ->orderBy('id') ->get() ->groupBy('account_short') ->map(static fn ($rows) => $rows->last()); diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index fd44cef1..403cafce 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -64,6 +64,7 @@ class FinanceLedgerPageData 'ledgerTransactionAccountHeaderOptions' => $this->transactionAccountHeaderOptions(), 'ledgerTransactionDateHeaderOptions' => $this->transactionDateHeaderOptions(), 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), + 'ledgerCompanyOptions' => $this->companyOptions(), 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerAccountPlanOptions' => $this->accountPlanOptions(), 'ledgerAccountKeepOptions' => FinanceLedgerAccountShouldKeep::OPTIONS, @@ -562,6 +563,25 @@ class FinanceLedgerPageData ->all(); } + private function companyOptions(): array + { + return $this->db()->table('fin_companies') + ->whereNotNull('name') + ->pluck('name') + ->merge( + $this->db()->table('fin_accounts') + ->whereNotNull('company_name') + ->distinct() + ->pluck('company_name'), + ) + ->map(static fn (mixed $value): string => trim((string) $value)) + ->filter() + ->unique() + ->sortBy(static fn (string $value): string => mb_strtolower($value)) + ->values() + ->all(); + } + private function applyLedgerHeaderFilter(Builder $query, string $tab, array $columns): void { $column = trim((string) request($tab . '_filter_column', '')); diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 6bbf39ab..6dbbda1d 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -10,6 +10,7 @@ $accountFilterOptions = $ledgerAccountFilterOptions ?? ['regions' => [], 'types' => []]; $accountTypeOptions = $accountFilterOptions['types'] ?? []; $accountPlanOptions = $ledgerAccountPlanOptions ?? []; + $companyOptions = $ledgerCompanyOptions ?? []; $accountKeepOptions = $ledgerAccountKeepOptions ?? \App\Utilities\FinanceLedgerAccountShouldKeep::OPTIONS; $activeAccountFilterCount = count(array_filter([ request('accounts_q'), @@ -200,6 +201,28 @@ .ledger-edit-switch input:focus-visible + .ledger-edit-switch__track { outline:2px solid var(--vip-content-accent-dark, #856632); outline-offset:2px; } .ledger-edit-switch:has(input:checked) { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); } .ledger-edit-switch.is-disabled { cursor:not-allowed; opacity:.55; } + .ledger-add-company[hidden] { display:none !important; } + .ledger-company-dialog { width:min(54rem, calc(100vw - 2rem)); max-height:calc(100vh - 2rem); padding:0; overflow:auto; border:1px solid var(--vip-border, #D8D2C7); border-radius:.75rem; background:#fff; color:var(--vip-content-ink, #171717); box-shadow:0 8px 24px rgba(23,23,23,.18); } + .ledger-company-dialog::backdrop { background:rgba(5,5,6,.46); } + .ledger-company-form { margin:0; } + .ledger-company-form__header { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; padding:1rem 1.1rem .85rem; border-bottom:1px solid #e8e3da; } + .ledger-company-form__title { margin:0 0 .22rem; font-size:.92rem; line-height:1.35; } + .ledger-company-form__help { margin:0; color:var(--vip-content-muted, #595959); font-size:.7rem; line-height:1.55; } + .ledger-company-form__close { display:inline-grid; flex:0 0 auto; place-items:center; width:1.8rem; height:1.8rem; padding:0; border:1px solid transparent; border-radius:.35rem; background:transparent; color:var(--vip-content-muted, #595959); cursor:pointer; } + .ledger-company-form__close:hover { border-color:var(--vip-border, #D8D2C7); color:var(--vip-content-accent-dark, #856632); } + .ledger-company-form__body { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:.8rem; padding:1rem 1.1rem; } + .ledger-company-field { display:grid; min-width:0; gap:.3rem; } + .ledger-company-field--wide { grid-column:span 2; } + .ledger-company-field label { color:var(--vip-content-muted, #595959); font-size:.7rem; font-weight:600; } + .ledger-company-field input, + .ledger-company-field select { width:100%; min-width:0; height:2.15rem; padding:0 .58rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; } + .ledger-company-field input:focus, + .ledger-company-field select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } + .ledger-company-field__hint { color:var(--vip-content-muted, #595959); font-size:.65rem; line-height:1.45; } + .ledger-company-form__message { grid-column:1 / -1; margin:0; padding:.55rem .65rem; border:1px solid rgba(184,0,0,.28); border-radius:.4rem; background:#fff5f5; color:var(--vip-danger, #B80000); font-size:.7rem; line-height:1.5; } + .ledger-company-form__message[hidden] { display:none !important; } + .ledger-company-form__footer { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; padding:.8rem 1.1rem 1rem; border-top:1px solid #e8e3da; } + .ledger-company-form__footer .ledger-btn:disabled { opacity:.58; cursor:wait; } #ledger-panel-accounts .ledger-account-editor { display:none; } #ledger-panel-accounts.is-account-edit-mode .ledger-account-readonly { display:none; } #ledger-panel-accounts.is-account-edit-mode .ledger-account-editor { display:block; } @@ -384,6 +407,8 @@ .ledger-source-tag { display:inline-flex; align-items:center; padding:.1rem .4rem; border-radius:.25rem; background:#f4f1eb; color:var(--vip-content-muted, #595959); font-size:.68rem; } .ledger-mono { font-variant-numeric:tabular-nums; } @media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } } + @media (max-width: 760px) { .ledger-company-form__body { grid-template-columns:repeat(2, minmax(0, 1fr)); } .ledger-company-field--wide { grid-column:span 2; } } + @media (max-width: 520px) { .ledger-company-form__body { grid-template-columns:minmax(0, 1fr); } .ledger-company-field--wide { grid-column:auto; } }
@@ -425,11 +450,17 @@ @if ($activeAccountFilterCount > 0) 筛选中{{ $activeAccountFilterCount }} @endif -