feat: 批量关联店铺提现账户映射

This commit is contained in:
shanshi 2026-10-20 12:13:25 +08:00
parent 1d9b5fd153
commit 40a1ea250d
7 changed files with 303 additions and 1 deletions

View File

@ -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']);
}
}

View File

@ -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。

View File

@ -0,0 +1,117 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use Illuminate\Support\Facades\DB;
/**
* 按店铺台账批量建立“店铺提现”来源值到账户简称的映射。
*
* 只处理店铺名称完全一致、回款账户简称存在于账户总表的未映射记录,
* 其余来源值保留人工确认。
*/
class FinanceLedgerShopMappingBulk
{
public function preview(): array
{
$connection = $this->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'));
}
}

View File

@ -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 @@
<div class="ledger-field" style="min-width:auto"><label>&nbsp;</label><label style="display:flex;align-items:center;gap:.4rem;height:2.15rem"><input type="checkbox" name="mapping_unmapped" value="1" @checked(request()->boolean('mapping_unmapped'))> 只看未映射</label></div>
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('mapping') }}"><span class="material-icons-outlined">refresh</span>重置</a></div>
</form>
<div class="ledger-note">{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }}</div>
<div class="ledger-note ledger-mapping-bulk">
<span>{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }}</span>
@if ($canEdit)
<button class="ledger-btn" type="button" data-shop-mapping-bulk data-preview-url="{{ route('business.financial-ledger.mapping.shop.preview') }}" data-apply-url="{{ route('business.financial-ledger.mapping.shop.apply') }}">
<span class="material-icons-outlined" aria-hidden="true">hub</span>按店铺台账批量关联
</button>
@endif
</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:64rem"><thead><tr><th>来源</th><th>原始值</th><th class="num">笔数</th><th class="num">收入合计</th><th class="num">支出合计</th><th>映射账户简称</th><th>状态</th></tr></thead>
<tbody>@forelse (($mapping['rows'] ?? collect()) as $row)<tr data-mapping-row data-id="{{ $row->id }}"><td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td><td>{{ $row->source_value }}</td><td class="num">{{ number_format((int) $row->txn_count) }}</td><td class="num">{{ $money($row->income_sum) }}</td><td class="num">{{ $money($row->expense_sum) }}</td><td>@if ($canEdit)<input class="ledger-map-input" list="ledger-account-list" value="{{ $row->account_short }}" placeholder="选择账户简称" data-mapping-input data-id="{{ $row->id }}" data-url="{{ route('business.financial-ledger.mapping.update', ['id' => $row->id]) }}">@else{{ $row->account_short ?: '未匹配' }}@endif</td><td data-mapping-state class="{{ $row->account_short ? 'success' : 'danger' }}">{{ $row->account_short ? '已映射' : '未匹配' }}</td></tr>@empty<tr><td colspan="7"><div class="ledger-empty">没有匹配的映射记录。</div></td></tr>@endforelse</tbody>
@ -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;

View File

@ -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');

View File

@ -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);

View File

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerShopMappingBulkTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}