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 @@
refresh重置
-
{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }}
+
+ {{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }} + @if ($canEdit) + + @endif +
@forelse (($mapping['rows'] ?? collect()) as $row)@empty@endforelse @@ -329,6 +338,44 @@ notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); }; + const shopMappingBulk = page.querySelector('[data-shop-mapping-bulk]'); + if (shopMappingBulk) { + const postBulkAction = async (url) => { + const response = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '操作失败'); + + return payload; + }; + shopMappingBulk.addEventListener('click', async () => { + shopMappingBulk.disabled = true; + try { + const preview = await postBulkAction(shopMappingBulk.dataset.previewUrl); + if (Number(preview.matched || 0) === 0) { + notify('没有可按店铺台账自动关联的未映射记录,剩余 ' + Number(preview.unmatched || 0) + ' 条需手工处理。', true); + return; + } + if (!window.confirm('可自动关联 ' + preview.matched + ' 条,尚有 ' + preview.unmatched + ' 条未匹配。确定继续吗?')) { + return; + } + const result = await postBulkAction(shopMappingBulk.dataset.applyUrl); + notify('已按店铺台账关联 ' + result.mapped + ' 条,剩余 ' + result.unmatched + ' 条未匹配。'); + window.setTimeout(() => window.location.reload(), 900); + } catch (error) { + notify(error.message || '批量关联失败', true); + } finally { + shopMappingBulk.disabled = false; + } + }); + } + const inputs = Array.from(page.querySelectorAll('[data-mapping-input]')); inputs.forEach((input) => { let previous = input.value; diff --git a/routes/admin.php b/routes/admin.php index 5df1afd5..bf091a62 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -645,6 +645,14 @@ Route::post('workspace/financial-ledger/accounts/type', [\App\Http\Controllers\B ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.accounts.type'); +Route::post('workspace/financial-ledger/mapping/shop/preview', [\App\Http\Controllers\Business\FinanceLedger::class, 'previewShopMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.shop.preview'); + +Route::post('workspace/financial-ledger/mapping/shop/apply', [\App\Http\Controllers\Business\FinanceLedger::class, 'applyShopMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.shop.apply'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index bbfe3554..7e3bda83 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -63,6 +63,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('name="accounts_type"', false) ->assertSee('data-account-type', false) ->assertSee('ledger-type-select', false) + ->assertSee('data-shop-mapping-bulk', false) ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); diff --git a/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php b/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php new file mode 100644 index 00000000..a53d5e83 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php @@ -0,0 +1,94 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + ['company_name' => '店铺映射测试公司', 'account_short' => '映射账户甲'], + ['company_name' => '店铺映射测试公司', 'account_short' => '映射账户乙'], + ]); + $connection->table('fin_shops')->insert([ + ['shop_name' => '天猫-映射店铺甲', 'account_short' => '映射账户甲'], + ['shop_name' => '天猫-映射店铺乙', 'account_short' => '映射账户乙'], + ['shop_name' => '天猫-映射店铺丙', 'account_short' => '/'], + ]); + $connection->table('fin_account_map')->insert([ + ['source' => 'shop', 'source_value' => '天猫-映射店铺甲', 'account_short' => null], + ['source' => 'shop', 'source_value' => '天猫-映射店铺乙', 'account_short' => null], + ['source' => 'shop', 'source_value' => '天猫-映射店铺丙', 'account_short' => null], + ['source' => 'shop', 'source_value' => '已经映射的店铺', 'account_short' => '映射账户甲'], + ]); + } + + private function previewUrl(): string + { + return route('business.financial-ledger.mapping.shop.preview', [ + 'company_id' => $this->company->id, + ]); + } + + private function applyUrl(): string + { + return route('business.financial-ledger.mapping.shop.apply', [ + 'company_id' => $this->company->id, + ]); + } + + public function test_preview_and_apply_only_exact_shop_name_matches(): void + { + $this->loginAs()->postJson($this->previewUrl()) + ->assertOk() + ->assertJsonPath('total', 3) + ->assertJsonPath('matched', 2) + ->assertJsonPath('unmatched', 1) + ->assertJsonCount(2, 'sample'); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺甲', + 'account_short' => null, + ], 'business_mirror'); + + $this->loginAs()->postJson($this->applyUrl()) + ->assertOk() + ->assertJsonPath('mapped', 2) + ->assertJsonPath('unmatched', 1); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺甲', + 'account_short' => '映射账户甲', + 'updated_by' => 'Test User', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺乙', + 'account_short' => '映射账户乙', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺丙', + 'account_short' => null, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '已经映射的店铺', + 'account_short' => '映射账户甲', + ], 'business_mirror'); + $this->assertDatabaseCount('fin_account_map_events', 2, 'business_mirror'); + } +}
来源原始值笔数收入合计支出合计映射账户简称状态
{{ $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 ? '已映射' : '未匹配' }}
没有匹配的映射记录。