From db08b2c2642d2e194aa8c21b2bfcc8167e5ebc08 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Tue, 20 Oct 2026 17:29:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=A2=9E=E8=B4=A2=E5=8A=A1=E6=AF=94=E5=AF=B9?= =?UTF-8?q?=E6=B1=87=E6=80=BB=E5=AF=BC=E5=87=BA=20(#407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 财务端比对明细新增CSV导出,包含筛选汇总和完整比对结果。 --- .../Business/SupplierReconciliation.php | 19 +++ .../SupplierComparisonCsvExporter.php | 134 ++++++++++++++++++ .../finance-data.blade.php | 6 + routes/admin.php | 4 + .../Business/SupplierReconciliationTest.php | 54 +++++++ 5 files changed, 217 insertions(+) create mode 100644 modules/EcommerceFinance/Services/SupplierComparisonCsvExporter.php diff --git a/app/Http/Controllers/Business/SupplierReconciliation.php b/app/Http/Controllers/Business/SupplierReconciliation.php index b5c41f38..950d9df8 100644 --- a/app/Http/Controllers/Business/SupplierReconciliation.php +++ b/app/Http/Controllers/Business/SupplierReconciliation.php @@ -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([ diff --git a/modules/EcommerceFinance/Services/SupplierComparisonCsvExporter.php b/modules/EcommerceFinance/Services/SupplierComparisonCsvExporter.php new file mode 100644 index 00000000..cad1c903 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierComparisonCsvExporter.php @@ -0,0 +1,134 @@ + $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 $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; + } +} diff --git a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php index 70c39654..f4d34be1 100644 --- a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php @@ -140,6 +140,12 @@ ← 返回供应商列表 + 导出比对汇总 下载对账单模板 @if ($differenceCount > 0)
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') diff --git a/tests/Feature/Business/SupplierReconciliationTest.php b/tests/Feature/Business/SupplierReconciliationTest.php index 0084e0c0..db469863 100644 --- a/tests/Feature/Business/SupplierReconciliationTest.php +++ b/tests/Feature/Business/SupplierReconciliationTest.php @@ -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('晨丰', [