财务台账:补齐账户后4位并统一支付宝占位 #442

Merged
shanshi merged 1 commits from feature/shanshi-ledger-account-last4-completion into main 2026-10-21 17:37:26 +08:00
6 changed files with 131 additions and 20 deletions

View File

@ -386,11 +386,10 @@ class FinanceLedger extends Controller
abort_if($accountShort === '', 422, '账户简称不能为空。');
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
abort_unless(
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
422,
'账户简称不存在,请刷新账户总表后重试。',
);
$account = $connection->table('fin_accounts')
->where('account_short', $accountShort)
->first(['account_no', 'account_last4']);
abort_if($account === null, 422, '账户简称不存在,请刷新账户总表后重试。');
if ($accountType !== '') {
$allowedTypes = FinanceLedgerAccountType::options(
@ -412,21 +411,36 @@ class FinanceLedger extends Controller
'updated_by' => (string) user()->name,
'updated_at' => $now,
];
$accountLast4 = FinanceLedgerAccountType::last4(
$accountType,
(string) ($account->account_no ?? ''),
(string) ($account->account_last4 ?? ''),
);
if ($existing === null) {
$overrides->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
} else {
$overrides->where('account_short', $accountShort)->update($values);
}
$connection->transaction(function () use ($connection, $overrides, $existing, $values, $accountShort, $now, $accountLast4): void {
if ($existing === null) {
$overrides->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
} else {
$overrides->where('account_short', $accountShort)->update($values);
}
$connection->table('fin_accounts')
->where('account_short', $accountShort)
->update([
'account_last4' => $accountLast4,
'updated_at' => $now,
]);
});
return response()->json([
'ok' => true,
'account_short' => $accountShort,
'account_type' => $accountType,
'label' => $accountType !== '' ? $accountType : '未填写',
'account_last4' => $accountLast4,
], 200, ['Cache-Control' => 'private, no-store']);
}

View File

@ -4,10 +4,14 @@ namespace App\Utilities;
final class FinanceLedgerAccountType
{
public const ALIPAY = '支付宝';
public const BANK_ACCOUNT_TYPES = ['公户', '个人卡', '网商银行'];
public const DEFAULT_OPTIONS = [
'个人卡',
'公户',
'支付宝',
self::ALIPAY,
'网商银行',
];
@ -28,4 +32,23 @@ final class FinanceLedgerAccountType
return $types;
}
public static function last4(?string $accountType, ?string $accountNo, ?string $fallback = null): string
{
$type = trim((string) $accountType);
if ($type === self::ALIPAY) {
return '/';
}
if (in_array($type, self::BANK_ACCOUNT_TYPES, true)) {
$digits = preg_replace('/\D+/', '', (string) $accountNo);
if (strlen((string) $digits) >= 4) {
return substr((string) $digits, -4);
}
}
$fallback = trim((string) $fallback);
return $fallback !== '' ? $fallback : '/';
}
}

View File

@ -3,6 +3,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
@ -12,8 +13,8 @@ use RuntimeException;
/**
* 导入财务台账的基础数据:账户、店铺、公司和期初余额。
*
* 源文件是财务维护的《银行当前余额-*.xlsx》。这里只做结构校验和全量
* 覆盖,不推断业务口径;导入完成后由 FinanceLedgerCalculator 重算流水。
* 源文件是财务维护的《银行当前余额-*.xlsx》。这里只做结构校验、后4位
* 规范化和全量覆盖,不推断其他业务口径;导入完成后由计算器重算流水。
*/
class FinanceLedgerBaseImporter
{
@ -121,19 +122,25 @@ class FinanceLedgerBaseImporter
}
$keepIndex = $headerIndexes['账户是否保留'] ?? null;
$accountNo = $this->text($value('账号', 2));
$accountType = $this->text($value('账户类型', 5));
$rows[] = [
'company_name' => $company,
'account_short' => $short,
'region' => null,
'account_no' => $this->text($value('账号', 2)),
'account_no' => $accountNo,
'bank_name' => $this->text($value('开户行', 3)),
'bank_city_branch' => $this->text($value('开户城市/网点', 4)),
'account_type' => $this->text($value('账户类型', 5)),
'account_type' => $accountType,
'company_plan' => $this->text($value('公司规划', 6)),
'account_should_keep' => FinanceLedgerAccountShouldKeep::normalize(
$keepIndex === null ? null : $this->text($row[$keepIndex] ?? null),
),
'account_last4' => $this->text($value('后4位', 7)),
'account_last4' => FinanceLedgerAccountType::last4(
$accountType,
$accountNo,
$this->text($value('后4位', 7)),
),
'single_limit' => $this->text($value('单笔限额', 8)),
'daily_limit' => $this->text($value('日限额', 9)),
'monthly_limit' => $this->text($value('月限额', 10)),

View File

@ -323,7 +323,7 @@
</select>
@endif
</td>
<td>{{ $row->account_last4 }}</td><td class="center" title="{{ $moneyYuanTitle($row->single_limit) }}">{{ $moneyWan($row->single_limit) }}</td><td class="center" title="{{ $moneyYuanTitle($row->daily_limit) }}">{{ $moneyWan($row->daily_limit) }}</td><td class="center" title="{{ $moneyYuanTitle($row->monthly_limit) }}">{{ $moneyWan($row->monthly_limit) }}</td><td class="center" title="{{ $moneyYuanTitle($row->yearly_limit) }}">{{ $moneyWan($row->yearly_limit) }}</td><td class="num" data-account-current-balance="{{ (float) $row->current_balance }}" title="{{ $moneyYuanTitle($row->current_balance) }}">{{ $moneyWan($row->current_balance) }}</td><td class="num" data-account-available-amount title="{{ $moneyYuanTitle($row->daily_available) }}">{{ $moneyWan($row->daily_available) }}</td><td class="num" data-account-blocked-amount title="{{ $moneyYuanTitle($row->blocked_amount) }}">{{ $moneyWan($row->blocked_amount) }}</td><td class="num" data-account-daily-available-amount title="{{ $moneyYuanTitle($row->daily_available) }}">{{ $moneyWan($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td title="{{ $moneyYuanTitle($row->raise_target) }}">{{ $moneyWan($row->raise_target) }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
<td class="center" data-account-last4>{{ $row->account_last4 }}</td><td class="center" title="{{ $moneyYuanTitle($row->single_limit) }}">{{ $moneyWan($row->single_limit) }}</td><td class="center" title="{{ $moneyYuanTitle($row->daily_limit) }}">{{ $moneyWan($row->daily_limit) }}</td><td class="center" title="{{ $moneyYuanTitle($row->monthly_limit) }}">{{ $moneyWan($row->monthly_limit) }}</td><td class="center" title="{{ $moneyYuanTitle($row->yearly_limit) }}">{{ $moneyWan($row->yearly_limit) }}</td><td class="num" data-account-current-balance="{{ (float) $row->current_balance }}" title="{{ $moneyYuanTitle($row->current_balance) }}">{{ $moneyWan($row->current_balance) }}</td><td class="num" data-account-available-amount title="{{ $moneyYuanTitle($row->daily_available) }}">{{ $moneyWan($row->daily_available) }}</td><td class="num" data-account-blocked-amount title="{{ $moneyYuanTitle($row->blocked_amount) }}">{{ $moneyWan($row->blocked_amount) }}</td><td class="num" data-account-daily-available-amount title="{{ $moneyYuanTitle($row->daily_available) }}">{{ $moneyWan($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td title="{{ $moneyYuanTitle($row->raise_target) }}">{{ $moneyWan($row->raise_target) }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
</tr>
@empty
<tr><td colspan="27"><div class="ledger-empty">没有匹配的账户数据。</div></td></tr>
@ -1001,6 +1001,10 @@
item.closest('td')?.setAttribute('data-account-should-keep', value);
}
});
if (field === 'account_type') {
const last4Cell = select.closest('tr')?.querySelector('[data-account-last4]');
if (last4Cell) last4Cell.textContent = payload.account_last4 || '/';
}
if (field === 'status') {
const row = select.closest('tr');
const currentCell = row?.querySelector('[data-account-current-balance]');

View File

@ -610,6 +610,42 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
])->assertUnprocessable()->assertJsonPath('message', '请选择现有账户类型。');
}
public function test_account_type_change_derives_last4_and_alipay_uses_slash(): void
{
DB::connection('business_mirror')->table('fin_accounts')
->where('account_short', '测试账户')
->update([
'account_no' => '6222-1234 5678',
'account_last4' => '0000',
]);
$this->loginAs()->postJson($this->typeUrl(), [
'account_short' => '测试账户',
'account_type' => '网商银行',
])
->assertOk()
->assertJsonPath('account_last4', '5678');
$this->assertDatabaseHas('fin_accounts', [
'account_short' => '测试账户',
'account_last4' => '5678',
], 'business_mirror');
$this->loginAs()->postJson($this->typeUrl(), [
'account_short' => '测试账户',
'account_type' => '支付宝',
])
->assertOk()
->assertJsonPath('account_last4', '/');
$this->assertDatabaseHas('fin_accounts', [
'account_short' => '测试账户',
'account_last4' => '/',
], 'business_mirror');
$this->loginAs()->get($this->pageUrl())
->assertOk()
->assertSee('<td class="center" data-account-last4>/</td>', false);
}
public function test_account_status_can_be_saved_and_updated_without_duplicate_rows(): void
{
$payload = [

View File

@ -0,0 +1,27 @@
<?php
namespace Tests\Unit;
use App\Utilities\FinanceLedgerAccountType;
use PHPUnit\Framework\TestCase;
class FinanceLedgerAccountTypeTest extends TestCase
{
public function test_bank_account_types_use_the_last_four_digits_of_the_account_number(): void
{
foreach (FinanceLedgerAccountType::BANK_ACCOUNT_TYPES as $type) {
$this->assertSame('5678', FinanceLedgerAccountType::last4($type, '6222-1234 5678', '0000'));
}
}
public function test_alipay_always_uses_a_slash(): void
{
$this->assertSame('/', FinanceLedgerAccountType::last4('支付宝', 'alipay@example.com', '1234'));
}
public function test_other_types_keep_the_fallback_when_no_bank_last_four_can_be_derived(): void
{
$this->assertSame('1234', FinanceLedgerAccountType::last4('其他', 'alipay@example.com', '1234'));
$this->assertSame('/', FinanceLedgerAccountType::last4('公户', '123', ''));
}
}