feat: 批量关联店铺提现账户映射
This commit is contained in:
parent
1d9b5fd153
commit
40a1ea250d
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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。
|
||||
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
@ -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> </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;
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
94
tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php
Normal file
94
tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user