供应商对账:新增财务比对汇总导出 (#407)

财务端比对明细新增CSV导出,包含筛选汇总和完整比对结果。
This commit is contained in:
renshen 2026-10-20 17:29:46 +08:00
parent 32cd0d0a15
commit db08b2c264
5 changed files with 217 additions and 0 deletions

View File

@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
@ -29,6 +30,7 @@ class SupplierReconciliation extends Controller
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
) {
$this->middleware('auth');
$this->middleware('company.identify');
@ -105,6 +107,23 @@ class SupplierReconciliation extends Controller
return $this->returnCsvExporter->download($rows, $supplier);
}
public function exportComparison(Request $request): StreamedResponse
{
$request->validate([
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
return $this->comparisonCsvExporter->download(
$detail,
$supplier,
trim((string) $request->query('month', '')),
trim((string) $request->query('reconciler', '')),
);
}
public function saveDingTalkRobot(Request $request): RedirectResponse
{
$request->validate([

View File

@ -0,0 +1,134 @@
<?php
namespace Modules\EcommerceFinance\Services;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** 财务端货款对账比对汇总 CSV。 */
class SupplierComparisonCsvExporter
{
private const HEADERS = [
'采购单号', '日期', '条码', '厂家条码', '比对结果',
'我方数量', '我方单价', '我方金额',
'厂家数量', '厂家单价', '厂家金额',
'数量差异', '金额差异', '结算口径', '结算金额',
];
/**
* @param array<string,mixed> $detail
*/
public function download(
array $detail,
string $supplier,
string $month,
string $reconciler,
): StreamedResponse {
$safeSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家';
$period = $month !== '' ? preg_replace('/[^0-9-]+/u', '', $month) : '全部月份';
$filename = $safeSupplier . '_货款对账比对_' . $period . '_' . now()->format('Ymd_His') . '.csv';
$summary = (array) ($detail['summary'] ?? []);
$rows = (array) ($detail['rows'] ?? []);
return response()->streamDownload(function () use ($detail, $rows, $summary, $supplier, $month, $reconciler): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fwrite($csv, "\xEF\xBB\xBF");
$this->write($csv, ['供应商', $supplier, '月份', $month !== '' ? $month : '全部月份', '对账人', $reconciler !== '' ? $reconciler : '全部']);
$this->write($csv, [
'我方数据行数', (int) ($detail['mineRows'] ?? 0),
'厂家数据行数', (int) ($detail['theirsRows'] ?? 0),
'比对结果条数', count($rows),
]);
$this->write($csv, [
'双方一致', (int) ($summary['match'] ?? 0),
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
]);
$this->write($csv, [
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
'厂家金额合计', $this->money($summary['theirsAmount'] ?? 0),
'厂家多算金额', $this->money($summary['supplierOverAmount'] ?? 0),
'按口径结算合计', $this->money($summary['settleAmount'] ?? 0),
'差异金额', $this->money($summary['differenceAmount'] ?? 0),
]);
$this->write($csv, []);
$this->write($csv, self::HEADERS);
foreach ($rows as $row) {
$this->write($csv, [
$row['purchaseNo'] ?? '',
$row['date'] ?? '',
$row['barcode'] ?? '',
$row['supplierBarcode'] ?? '',
$row['type'] ?? '',
$this->number($row['mineQty'] ?? 0),
$this->money($row['mineUnitPrice'] ?? 0),
$this->money($row['mineAmount'] ?? 0),
$this->number($row['theirsQty'] ?? 0),
$this->money($row['theirsUnitPrice'] ?? 0),
$this->money($row['theirsAmount'] ?? 0),
$this->quantityDifference($row['qtyDifference'] ?? 0),
$this->amountDifference($row['amountDifference'] ?? 0),
$row['note'] ?? '',
$this->money($row['settleAmount'] ?? 0),
]);
}
fclose($csv);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @param resource $csv @param list<mixed> $values */
private function write($csv, array $values): void
{
fputcsv($csv, array_map(fn (mixed $value): string => $this->csvCell($value), $values));
}
private function quantityDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ' : '少 ') . $this->number(abs($difference));
}
private function amountDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ¥' : '少 ¥') . $this->money(abs($difference));
}
private function number(mixed $value): string
{
return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.');
}
private function money(mixed $value): string
{
return number_format((float) $value, 2, '.', '');
}
private function csvCell(mixed $value): string
{
$text = trim((string) $value);
if (is_numeric(str_replace(',', '', $text))) {
return $text;
}
return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text;
}
}

View File

@ -140,6 +140,12 @@
</span>
<span style="flex:1 1 auto"></span>
<a href="{{ $listUrl }}" class="sr-button">← 返回供应商列表</a>
<a href="{{ route('business.supplier-reconciliation.comparison.export', array_filter([
'supplier' => $selectedSupplier,
'month' => $month,
'reconciler' => $reconciler,
], static fn (mixed $value): bool => $value !== '' && $value !== null)) }}"
class="sr-button">导出比对汇总</a>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
@if ($differenceCount > 0)
<form method="POST"

View File

@ -409,6 +409,10 @@ Route::get('workspace/supplier-reconciliation/returns/export', [SupplierReconcil
->middleware('business.page:supplier-reconciliation,supplier-data')
->name('business.supplier-reconciliation.returns.export');
Route::get('workspace/supplier-reconciliation/comparison/export', [SupplierReconciliationActions::class, 'exportComparison'])
->middleware('business.page:supplier-reconciliation,finance-data')
->name('business.supplier-reconciliation.comparison.export');
// 退货扣减确认:逐行确认/异议 + 一键全部确认
Route::post('workspace/supplier-reconciliation/returns/confirm', [SupplierReconciliationActions::class, 'confirmReturn'])
->middleware('business.page:supplier-reconciliation,supplier-data')

View File

@ -115,6 +115,7 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('厂家多 1.00')
->assertSeeText('厂家多 ¥128.00')
->assertSeeText('编号-ZSJ-1N1097V01')
->assertSeeText('导出比对汇总')
->assertSee('sr-comparison-scroll', false)
->assertSeeText('每页 15 条')
->assertSeeText('ZSJ-3')
@ -210,6 +211,59 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertDontSeeText('A-01');
}
public function test_finance_comparison_export_includes_summary_and_all_filtered_rows(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach ([
'mine:1' => ['purchaseNo' => 'PO-1', 'skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200', 'reconciler' => '张三'],
'mine:2' => ['purchaseNo' => 'PO-2', 'skuCode' => 'B-1', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三'],
'mine:other' => ['purchaseNo' => 'PO-OTHER', 'skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30', 'reconciler' => '李四'],
] as $key => $row) {
$store->save('final:supplier:payment-reconciliation', $key, $row + [
'summary_month' => $key === 'mine:other' ? '2026-10' : '2026-09',
'date' => '2026-09-30',
'supplier' => '晨丰',
], $companyId, null);
}
$this->seedStatement('晨丰', [
array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), ['purchaseNo' => 'PO-1']),
array_merge($this->statementRow('B-1N1097V01', '50.00', '2', '100.00'), ['purchaseNo' => 'PO-2']),
array_merge($this->statementRow('D-1N1097V01', '80.00', '1', '80.00'), ['purchaseNo' => 'PO-D']),
]);
$response = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [
'supplier' => '晨丰',
'month' => '2026-09',
'reconciler' => '张三',
]));
$content = $response->streamedContent();
$response->assertOk()
->assertDownload()
->assertHeader('content-type', 'text/csv; charset=UTF-8');
foreach ([
'供应商,晨丰',
'月份,2026-09',
'对账人,张三',
'双方一致,1',
'厂家多,1',
'数量/金额不一致,1',
'厂家少,0',
'采购单号,日期,条码,厂家条码,比对结果',
'PO-1',
'PO-2',
'PO-D',
'厂家多 1',
'厂家多 ¥50.00',
] as $expected) {
$this->assertStringContainsString($expected, $content);
}
$this->assertStringNotContainsString('PO-OTHER', $content);
}
public function test_supplier_page_shows_uploads_and_return_rows(): void
{
$this->seedStatement('晨丰', [