feat: 导出财务台账账户录入模板

This commit is contained in:
shanshi 2026-10-20 13:38:30 +08:00
parent ea9ea0d5ac
commit aba8039b8d
7 changed files with 297 additions and 1 deletions

View File

@ -9,12 +9,19 @@ use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk;
use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinanceLedger extends Controller
{
public function downloadAccountTemplate(FinanceLedgerAccountTemplate $template): StreamedResponse
{
return $template->download();
}
public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。');

View File

@ -63,6 +63,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
- 路由:`/workspace/financial-ledger/ledger`
- 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源
- 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明
- 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读
- 调度:`finance-ledger:sync-rpt` 每天 02:30(Asia/Shanghai)

View File

@ -0,0 +1,200 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 财务台账账户总表空白录入模板。
*/
class FinanceLedgerAccountTemplate
{
private const MAX_ROWS = 1000;
private const HEADERS = [
'公司名称',
'账户简称',
'账户状态',
'地区',
'账号',
'开户行',
'开户城市/网点',
'账户类型',
'公司规划',
'后4位',
'单笔限额',
'日限额',
'月限额',
'年限额',
'当前余额',
'卡住金额',
'每日可出',
'预计出清天数',
'提额目标',
'提额状态',
'提额动作',
'已做动作',
'下一步',
'备注',
'责任人',
];
public function download(): StreamedResponse
{
$spreadsheet = $this->makeSpreadsheet();
return response()->streamDownload(function () use ($spreadsheet): void {
$writer = new Xlsx($spreadsheet);
$writer->setPreCalculateFormulas(false);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '财务台账-账户总表录入模板.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'no-store, no-cache, must-revalidate',
]);
}
private function makeSpreadsheet(): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getProperties()
->setTitle('财务台账账户总表录入模板')
->setSubject('账户总表重新录入');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('账户总表');
$lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS));
$lastRow = self::MAX_ROWS + 1;
foreach (self::HEADERS as $index => $header) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->setCellValue("{$column}1", $header);
$sheet->getColumnDimension($column)->setWidth($this->width($index));
}
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:{$lastColumn}1");
$sheet->getRowDimension(1)->setRowHeight(28);
$sheet->getStyle("A1:{$lastColumn}1")->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER)
->setWrapText(true);
$sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle("A1:{$lastColumn}1")->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF856632');
$sheet->getStyle("A1:{$lastColumn}{$lastRow}")->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7');
$sheet->getStyle("A2:N{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
$sheet->getStyle("O2:Q{$lastRow}")->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle("R2:R{$lastRow}")->getNumberFormat()->setFormatCode('0.00');
$sheet->getStyle("S2:Y{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
$options = $spreadsheet->createSheet();
$options->setTitle('选项');
$options->setCellValue('A1', '账户状态');
$options->setCellValue('B1', '账户类型');
$statuses = array_values(FinanceLedgerAccountStatus::OPTIONS);
$types = $this->accountTypes();
foreach ($statuses as $index => $status) {
$options->setCellValue('A' . ($index + 2), $status);
}
foreach ($types as $index => $type) {
$options->setCellValue('B' . ($index + 2), $type);
}
$options->setSheetState(Worksheet::SHEETSTATE_HIDDEN);
$this->applyListValidation($sheet, 'C', '\'选项\'!$A$2:$A$' . (count($statuses) + 1), '请选择账户状态');
if ($types !== []) {
$this->applyListValidation($sheet, 'H', '\'选项\'!$B$2:$B$' . (count($types) + 1), '请选择账户类型');
}
$notes = $spreadsheet->createSheet();
$notes->setTitle('填写说明');
$notes->fromArray([
['项目', '说明'],
['用途', '账户总表重新录入模板,不包含当前账户数据。'],
['必填', '公司名称、账户简称。'],
['账户状态', implode(' / ', $statuses)],
['账户类型', $types !== [] ? implode(' / ', $types) : '留空或按实际填写'],
['金额', '当前余额、卡住金额、每日可出按数字填写;限额列保留原有组合写法。'],
['提交前', '请核对账户数、余额合计和期初合计,再导入系统。'],
], null, 'A1');
$notes->getColumnDimension('A')->setWidth(16);
$notes->getColumnDimension('B')->setWidth(70);
$notes->getStyle('A1:B1')->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$notes->getStyle('A1:B1')->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF856632');
$notes->getStyle('A1:B7')->getAlignment()->setVertical(Alignment::VERTICAL_TOP)->setWrapText(true);
$notes->getStyle('A1:B7')->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7');
$notes->getRowDimension(1)->setRowHeight(26);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function applyListValidation(
Worksheet $sheet,
string $column,
string $formula,
string $prompt,
): void {
$validation = $sheet->getCell("{$column}2")->getDataValidation();
$validation->setType(DataValidation::TYPE_LIST);
$validation->setErrorStyle(DataValidation::STYLE_STOP);
$validation->setAllowBlank(true);
$validation->setShowInputMessage(true);
$validation->setPromptTitle('选择' . $prompt);
$validation->setPrompt($prompt);
$validation->setShowErrorMessage(true);
$validation->setErrorTitle('选项无效');
$validation->setError($prompt);
$validation->setFormula1($formula);
$validation->setShowDropDown(true);
for ($row = 2; $row <= self::MAX_ROWS + 1; $row++) {
$sheet->getCell("{$column}{$row}")->setDataValidation(clone $validation);
}
}
private function accountTypes(): array
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$base = $connection->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all();
$overrides = $connection->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all();
$types = array_values(array_unique(array_merge($base, $overrides)));
sort($types, SORT_NATURAL | SORT_FLAG_CASE);
return $types;
}
private function width(int $index): int
{
return match ($index) {
0, 5 => 30,
1, 4, 6, 23 => 22,
2, 3, 7, 8, 17 => 13,
14, 15, 16 => 16,
default => 18,
};
}
}

View File

@ -145,7 +145,7 @@
@if ($activeAccountFilterCount > 0)
<span class="ledger-filter-state" data-account-filter-state role="status" aria-live="polite" title="当前有 {{ $activeAccountFilterCount }} 个筛选条件生效"><span class="material-icons-outlined" aria-hidden="true">filter_alt</span><span>筛选中</span><b>{{ $activeAccountFilterCount }}</b></span>
@endif
<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('accounts') }}"><span class="material-icons-outlined">refresh</span>重置</a></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('accounts') }}"><span class="material-icons-outlined">refresh</span>重置</a><a class="ledger-btn" href="{{ route('business.financial-ledger.accounts.template') }}"><span class="material-icons-outlined">download</span>导出录入模板</a></div>
</form>
<div class="ledger-summary">
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>

View File

@ -645,6 +645,10 @@ Route::post('workspace/financial-ledger/accounts/type', [\App\Http\Controllers\B
->middleware('business.page:financial-ledger,ledger')
->name('business.financial-ledger.accounts.type');
Route::get('workspace/financial-ledger/accounts/template', [\App\Http\Controllers\Business\FinanceLedger::class, 'downloadAccountTemplate'])
->middleware('business.page:financial-ledger,ledger')
->name('business.financial-ledger.accounts.template');
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');

View File

@ -64,6 +64,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('data-account-type', false)
->assertSee('ledger-type-select', false)
->assertSee('data-mapping-bulk', false)
->assertSee('导出录入模板')
->assertSee('未填写')
->assertDontSee('data-account-filter-state', false)
->assertSee('value="normal" selected', false);

View File

@ -0,0 +1,83 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerAccountTemplateTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
DB::connection('business_mirror')->table('fin_accounts')->insert([
'company_name' => '模板测试公司',
'account_short' => '模板测试账户',
'account_type' => '公户',
]);
}
public function test_account_master_template_is_blank_and_contains_page_columns_and_validations(): void
{
$response = $this->loginAs()->get(route('business.financial-ledger.accounts.template', [
'company_id' => $this->company->id,
]));
$response->assertOk();
$this->assertSame(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
$response->headers->get('content-type'),
);
$path = tempnam(sys_get_temp_dir(), 'ledger-template-');
file_put_contents($path, $response->streamedContent());
try {
$spreadsheet = IOFactory::load($path);
$sheet = $spreadsheet->getSheetByName('账户总表');
$this->assertNotNull($sheet);
$this->assertSame([
'公司名称',
'账户简称',
'账户状态',
'地区',
'账号',
'开户行',
'开户城市/网点',
'账户类型',
'公司规划',
'后4位',
'单笔限额',
'日限额',
'月限额',
'年限额',
'当前余额',
'卡住金额',
'每日可出',
'预计出清天数',
'提额目标',
'提额状态',
'提额动作',
'已做动作',
'下一步',
'备注',
'责任人',
], $sheet->rangeToArray('A1:Y1')[0]);
$this->assertNull($sheet->getCell('A2')->getValue());
$this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('C2')->getDataValidation()->getType());
$this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('H2')->getDataValidation()->getType());
$this->assertSame(Worksheet::SHEETSTATE_HIDDEN, $spreadsheet->getSheetByName('选项')->getSheetState());
$this->assertNotNull($spreadsheet->getSheetByName('填写说明'));
$spreadsheet->disconnectWorksheets();
} finally {
@unlink($path);
}
}
}