diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 98be1e6b..950c1042 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -10,6 +10,7 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; +use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; class FinanceLedger extends Controller { @@ -165,4 +166,34 @@ class FinanceLedger extends Controller 'label' => $accountType !== '' ? $accountType : '未填写', ], 200, ['Cache-Control' => 'private, no-store']); } + + public function previewShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); + + $preview = $bulk->preview(); + + return response()->json([ + 'ok' => true, + 'total' => $preview['total'], + 'matched' => $preview['matched'], + 'unmatched' => $preview['unmatched'], + 'sample' => array_slice($preview['candidates'], 0, 10), + 'unmatched_sample' => array_slice($preview['unmatched_values'], 0, 10), + ], 200, ['Cache-Control' => 'private, no-store']); + } + + public function applyShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); + + $result = $bulk->apply(user()); + + return response()->json([ + 'ok' => true, + 'total' => $result['total'], + 'mapped' => $result['mapped'], + 'unmatched' => $result['unmatched'], + ], 200, ['Cache-Control' => 'private, no-store']); + } } diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 7e7e91ce..4700c3b5 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -70,6 +70,10 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ `fin_account_type_overrides`,重新导入账户基础数据均不会覆盖;页面修改使用 异步保存,不触发整页刷新。 +“账户映射”页支持按店铺台账批量关联:仅匹配 `source = shop`、来源值与 +`fin_shops.shop_name` 完全一致、店铺回款账户简称存在于 `fin_accounts` +的未映射记录。批量操作写入映射审计并重算流水,未精确匹配的来源值保留人工处理。 + 生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行 `schedule:run` 的容器,调度登记不能替代宿主机 cron。 diff --git a/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php b/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php new file mode 100644 index 00000000..7c2a0bbe --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php @@ -0,0 +1,117 @@ +connection(); + $shopAccounts = $connection->table('fin_shops') + ->whereNotNull('account_short') + ->where('account_short', '<>', '') + ->where('account_short', '<>', '/') + ->pluck('account_short', 'shop_name'); + $masterAccounts = array_fill_keys( + $connection->table('fin_accounts')->distinct()->pluck('account_short')->all(), + true, + ); + $rows = $connection->table('fin_account_map') + ->where('source', 'shop') + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->orderBy('source_value') + ->get(['id', 'source_value']); + + $candidates = []; + $unmatched = []; + foreach ($rows as $row) { + $sourceValue = trim((string) $row->source_value); + $accountShort = trim((string) ($shopAccounts[$sourceValue] ?? '')); + if ($accountShort === '' || ! isset($masterAccounts[$accountShort])) { + $unmatched[] = $sourceValue; + continue; + } + + $candidates[] = [ + 'id' => (int) $row->id, + 'source_value' => $sourceValue, + 'account_short' => $accountShort, + ]; + } + + return [ + 'total' => $rows->count(), + 'matched' => count($candidates), + 'unmatched' => count($unmatched), + 'candidates' => $candidates, + 'unmatched_values' => $unmatched, + ]; + } + + public function apply(?User $user = null): array + { + $preview = $this->preview(); + $connection = $this->connection(); + $userName = (string) ($user?->name ?? ''); + $userId = (int) ($user?->id ?? 0); + $now = now(); + $mapped = 0; + + if ($preview['candidates'] !== []) { + $connection->transaction(function () use ($connection, $preview, $userName, $userId, $now, &$mapped): void { + foreach ($preview['candidates'] as $candidate) { + $updated = $connection->table('fin_account_map') + ->where('id', $candidate['id']) + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->update([ + 'account_short' => $candidate['account_short'], + 'updated_by' => $userName, + 'updated_at' => $now, + ]); + if ($updated !== 1) { + continue; + } + + $connection->table('fin_account_map_events')->insert([ + 'source' => 'shop', + 'source_value' => $candidate['source_value'], + 'old_account_short' => null, + 'new_account_short' => $candidate['account_short'], + 'user_id' => $userId, + 'user_name' => $userName, + 'created_at' => $now, + ]); + $mapped++; + } + }); + } + + if ($mapped > 0) { + app(FinanceLedgerCalculator::class)->recalculate(); + } + + return [ + 'total' => $preview['total'], + 'mapped' => $mapped, + 'unmatched' => $preview['unmatched'], + ]; + } + + private function connection() + { + return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } +} diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 1ed8f04d..29e68316 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -51,6 +51,8 @@ .ledger-filter-state { display:inline-flex; align-items:center; align-self:flex-end; gap:.28rem; height:2.15rem; padding:0 .58rem; border:1px solid rgba(185,149,79,.42); border-radius:999px; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); font-size:.68rem; font-weight:700; white-space:nowrap; } .ledger-filter-state .material-icons-outlined { font-size:.95rem; } .ledger-filter-state b { display:inline-grid; place-items:center; min-width:1.15rem; height:1.15rem; padding:0 .2rem; border-radius:999px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:.6rem; line-height:1; } + .ledger-mapping-bulk { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; } + .ledger-mapping-bulk .ledger-btn { height:1.95rem; } .ledger-summary { display:flex; flex-wrap:wrap; gap:.6rem 1.5rem; padding:.7rem 1rem; margin-bottom:.85rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:var(--vip-content-accent-soft, #F7F0DE); } .ledger-summary__item { display:flex; flex-direction:column; gap:.12rem; min-width:7rem; } .ledger-summary__item span { color:var(--vip-content-muted, #595959); font-size:.68rem; } @@ -269,7 +271,14 @@
-| 来源 | 原始值 | 笔数 | 收入合计 | 支出合计 | 映射账户简称 | 状态 |
|---|---|---|---|---|---|---|
| {{ $sourceLabels[$row->source] ?? $row->source }} | {{ $row->source_value }} | {{ number_format((int) $row->txn_count) }} | {{ $money($row->income_sum) }} | {{ $money($row->expense_sum) }} | @if ($canEdit)@else{{ $row->account_short ?: '未匹配' }}@endif | {{ $row->account_short ? '已映射' : '未匹配' }} |
没有匹配的映射记录。 | ||||||