diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 80137f1a..dc3ef4af 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3283,6 +3283,8 @@ class Pages extends Controller 'preview-statistics' => $this->contentForPreviewStatistics(), 'planting-details' => $this->contentForPlantingDetails(), 'jst-order-labels' => $this->contentForJstOrderLabels(), + 'finance-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->financeData(), + 'supplier-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->supplierData(), 'store-report-data-logic' => $this->prepareStoreReportLogic(), 'tax-forecast-data-logic' => $this->contentForTaxForecastLogic(), default => null, diff --git a/app/Http/Controllers/Business/SupplierReconciliation.php b/app/Http/Controllers/Business/SupplierReconciliation.php new file mode 100644 index 00000000..ca401a94 --- /dev/null +++ b/app/Http/Controllers/Business/SupplierReconciliation.php @@ -0,0 +1,61 @@ +middleware('auth'); + $this->middleware('company.identify'); + } + + public function template(): BinaryFileResponse + { + $path = $this->template->path(); + abort_unless(is_file($path), 404, '对账单模板不存在。'); + + return response()->download($path, $this->template->filename()); + } + + public function upload(Request $request): JsonResponse|RedirectResponse + { + $request->validate([ + 'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'], + 'supplier' => ['required', 'string', 'max:191'], + ]); + + $supplier = trim((string) $request->input('supplier')); + if ($supplier === '') { + abort(422, '请选择厂家。'); + } + + $records = $this->template->parse($request->file('import'), $supplier); + $rows = $this->statements->save( + $supplier, + $records, + (string) $request->file('import')->getClientOriginalName(), + (int) (company_id() ?: 1), + user_id() ? (int) user_id() : null, + ); + + $message = "对账单已保存:{$supplier} 共 {$rows} 行。"; + + return $request->expectsJson() + ? response()->json(['message' => $message, 'rows' => $rows]) + : back()->with('success', $message); + } +} diff --git a/app/Listeners/Menu/ShowInAdmin.php b/app/Listeners/Menu/ShowInAdmin.php index 4e575566..cb0d9070 100644 --- a/app/Listeners/Menu/ShowInAdmin.php +++ b/app/Listeners/Menu/ShowInAdmin.php @@ -62,7 +62,7 @@ class ShowInAdmin public static function groupPayables(array $navigation): array { - $keys = ['supplier-payables-final', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables', + $keys = ['supplier-payables-final', 'supplier-reconciliation', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables', 'lamination-factory-payables', 'paper-materials', 'edge-banding-materials']; $children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true))); if ($children === []) { diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 297b93ce..aaea8c18 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -193,6 +193,17 @@ class SidebarPermissions // 「种菜明细表 / 聚水潭订单标签明细」原先挂在「账单流水数据」下,后来移入新的一级菜单 // 「种菜数据汇总」。按页面键保存过精确权限的账号(访问组/角色)里不会有新的 planting-data, // 会因此看不到这两个被移动的页面;这里按「能看账单流水数据 就能看这两个页面」兼容旧配置。 + // 「供应商对账」与「供应商应付(终版)」同属应付款项,先按同样的权限跟随授权 + // (厂家账号后续按单页面单独配置)。 + $visibleSlugs = array_column($navigation, 'slug'); + if (! in_array('supplier-reconciliation', $visibleSlugs, true) && in_array('supplier-payables-final', $visibleSlugs, true)) { + $reconciliationSection = collect(BusinessNavigation::all())->firstWhere('slug', 'supplier-reconciliation'); + if (! empty($reconciliationSection)) { + $index = array_search('supplier-payables-final', $visibleSlugs, true); + array_splice($navigation, $index + 1, 0, [$reconciliationSection]); + } + } + $slugs = array_column($navigation, 'slug'); if (! in_array('planting-data', $slugs, true) && in_array('billing-transactions', $slugs, true)) { $plantingSection = collect(BusinessNavigation::all())->firstWhere('slug', 'planting-data'); diff --git a/config/business_navigation.php b/config/business_navigation.php index 28be9414..fc30fae1 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -646,6 +646,15 @@ $navigation = [ ['slug' => 'supplier-payables-report', 'title' => '供应商应付表'], ], ], + [ + 'slug' => 'supplier-reconciliation', + 'title' => '供应商对账', + 'icon' => 'fact_check', + 'pages' => [ + ['slug' => 'finance-data', 'title' => '财务端数据'], + ['slug' => 'supplier-data', 'title' => '厂家端数据'], + ], + ], [ 'slug' => 'courier-payables-final', 'title' => '快递应付(终版)', diff --git a/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php b/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php index fbe190c6..bc8797e5 100644 --- a/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php +++ b/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php @@ -174,6 +174,56 @@ class SupplierPayableOverrideStore return $this->connection()->transaction($callback); } + /** + * 整份替换某个数据集(厂家对账单:以最新一版为准),不写逐行审计事件。 + * + * @param array> $records + */ + public function replaceAll(string $dataset, array $records, int $companyId, ?int $userId): int + { + $connection = $this->connection(); + $now = now(); + $rows = []; + foreach ($records as $key => $values) { + $rows[] = [ + 'company_id' => $companyId, + 'dataset' => $dataset, + 'record_key' => (string) $key, + 'values' => $this->encodeValues($values), + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + $connection->transaction(function () use ($connection, $dataset, $companyId, $rows): void { + $connection->table('supplier_payable_overrides') + ->where('company_id', $companyId) + ->where('dataset', $dataset) + ->delete(); + + foreach (array_chunk($rows, 500) as $chunk) { + $connection->table('supplier_payable_overrides')->insert($chunk); + } + }); + + return count($rows); + } + + /** @return list 某个前缀下已有的数据集名 */ + public function datasetsWithPrefix(string $prefix, int $companyId): array + { + return $this->connection()->table('supplier_payable_overrides') + ->where('company_id', $companyId) + ->where('dataset', 'like', addcslashes($prefix, '%_\\') . '%') + ->distinct() + ->orderBy('dataset') + ->pluck('dataset') + ->map(static fn (mixed $value): string => (string) $value) + ->all(); + } + /** 值编码为 JSON;脏字节(非法 UTF-8)用替代字符,避免整批写入失败。 */ private function encodeValues(array $values): string { diff --git a/modules/EcommerceFinance/Services/SupplierStatementMatcher.php b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php new file mode 100644 index 00000000..605dfb4f --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php @@ -0,0 +1,118 @@ +> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount) + * @param list> $theirs 厂家对账单(barcode/unitPrice/qty/amount) + * @return array{rows:list>,summary:array} + */ + public function compare(array $mine, array $theirs): array + { + $mineGrouped = $this->group($mine, 'skuCode'); + $theirsGrouped = $this->group($theirs, 'barcode'); + + $rows = []; + $summary = ['match' => 0, 'supplierExtra' => 0, 'mismatch' => 0, 'supplierMissing' => 0, + 'mineAmount' => 0.0, 'settleAmount' => 0.0]; + + foreach (array_unique(array_merge(array_keys($mineGrouped), array_keys($theirsGrouped))) as $key) { + $mineRow = $mineGrouped[$key] ?? null; + $theirsRow = $theirsGrouped[$key] ?? null; + + $barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode']); + $unitPrice = (float) ($mineRow['unitPrice'] ?? $theirsRow['unitPrice']); + $mineQty = (float) ($mineRow['qty'] ?? 0); + $mineAmount = (float) ($mineRow['amount'] ?? 0); + $theirsQty = (float) ($theirsRow['qty'] ?? 0); + $theirsAmount = (float) ($theirsRow['amount'] ?? 0); + + if ($mineRow === null) { + $type = self::TYPE_SUPPLIER_EXTRA; + $settle = 0.0; + $note = '我方没有这条数据:不结算,退回厂家修改账单'; + $summary['supplierExtra']++; + } elseif ($theirsRow === null) { + $type = self::TYPE_SUPPLIER_MISSING; + $settle = 0.0; + $note = '厂家少报了这条数据:按厂家数据结算(不付款)'; + $summary['supplierMissing']++; + } elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) { + $type = self::TYPE_MATCH; + $settle = $mineAmount; + $note = '正常结算'; + $summary['match']++; + } else { + $type = self::TYPE_MISMATCH; + $settle = min($mineAmount, $theirsAmount); + $note = '金额/数量不一致:按金额低的一方结算'; + $summary['mismatch']++; + } + + $summary['mineAmount'] += $mineAmount; + $summary['settleAmount'] += $settle; + + $rows[] = [ + 'barcode' => $barcode, + 'unitPrice' => $unitPrice, + 'mineQty' => $mineQty, + 'mineAmount' => $mineAmount, + 'theirsQty' => $theirsQty, + 'theirsAmount' => $theirsAmount, + 'type' => $type, + 'settleAmount' => $settle, + 'note' => $note, + ]; + } + + usort($rows, static fn (array $a, array $b): int => [$a['type'] === self::TYPE_MATCH ? 1 : 0, $a['barcode']] + <=> [$b['type'] === self::TYPE_MATCH ? 1 : 0, $b['barcode']]); + + $summary['mineAmount'] = round($summary['mineAmount'], 2); + $summary['settleAmount'] = round($summary['settleAmount'], 2); + $summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2); + + return ['rows' => $rows, 'summary' => $summary]; + } + + /** + * 按「条码 + 单价」归组,数量与金额相加。 + * + * @param list> $rows + * @return array + */ + private function group(array $rows, string $barcodeField): array + { + $grouped = []; + foreach ($rows as $row) { + $barcode = trim((string) ($row[$barcodeField] ?? '')); + if ($barcode === '') { + continue; + } + + $unitPrice = (float) ($row['unitPrice'] ?? 0); + $key = $barcode . '|' . number_format($unitPrice, 4, '.', ''); + + $grouped[$key] ??= ['barcode' => $barcode, 'unitPrice' => $unitPrice, 'qty' => 0.0, 'amount' => 0.0]; + $grouped[$key]['qty'] += (float) ($row['qty'] ?? 0); + $grouped[$key]['amount'] += (float) ($row['amount'] ?? 0); + } + + return $grouped; + } +} diff --git a/modules/EcommerceFinance/Services/SupplierStatementStore.php b/modules/EcommerceFinance/Services/SupplierStatementStore.php new file mode 100644 index 00000000..6779fc16 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierStatementStore.php @@ -0,0 +1,61 @@ + $record) { + $rows[sprintf('line:%06d', $index + 1)] = $record; + } + + $this->store->replaceAll(self::PREFIX . $supplier, $rows, $companyId, $userId); + $this->store->save(self::LOG_PREFIX . $supplier, 'upload:' . now()->format('YmdHis'), [ + 'uploaded_at' => now()->toDateTimeString(), + 'filename' => $filename, + 'rows' => count($records), + ], $companyId, $userId, 'import'); + + return count($records); + } + + /** @return list> 最新的对账单行 */ + public function rows(string $supplier, int $companyId): array + { + return $this->store->listRows(self::PREFIX . $supplier, $companyId); + } + + /** @return list> 最近的上传记录(新到旧) */ + public function uploads(string $supplier, int $companyId, int $limit = 10): array + { + $uploads = $this->store->listRows(self::LOG_PREFIX . $supplier, $companyId); + usort($uploads, static fn (array $a, array $b): int => strcmp((string) ($b['uploaded_at'] ?? ''), (string) ($a['uploaded_at'] ?? ''))); + + return array_slice($uploads, 0, $limit); + } + + /** @return list 已经上传过对账单的厂家名称 */ + public function uploadedSuppliers(int $companyId): array + { + return array_values(array_map( + static fn (string $dataset): string => substr($dataset, strlen(self::LOG_PREFIX)), + $this->store->datasetsWithPrefix(self::LOG_PREFIX, $companyId), + )); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierStatementTemplate.php b/modules/EcommerceFinance/Services/SupplierStatementTemplate.php new file mode 100644 index 00000000..852562f3 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierStatementTemplate.php @@ -0,0 +1,157 @@ + '快递名称', + 'expressNo' => '快递单号', + 'purchaseNo' => '单据编号(采购单号)', + 'createdAt' => '创建时间', + 'supplier' => '供应商', + 'barcode' => '条码', + 'skuName' => '规格名称', + 'qty' => '数量', + 'unitPrice' => '单价', + 'amount' => '金额', + ]; + + private const MAX_ROWS = 200000; + + /** 允许留空的列。 */ + private const OPTIONAL_COLUMNS = ['expressName', 'expressNo', 'purchaseNo']; + + /** 必须是数字的列。 */ + private const NUMERIC_COLUMNS = ['qty', 'unitPrice', 'amount']; + + public function path(): string + { + return resource_path('templates/supplier-reconciliation/statement-template.xlsx'); + } + + public function filename(): string + { + return '供应商对账单模板.xlsx'; + } + + /** + * 解析并严格校验厂家上传的对账单。 + * + * @return list> 模块内部键的记录(条码/单价/数量/金额等) + */ + public function parse(UploadedFile $file, ?string $expectedSupplier = null): array + { + $extension = strtolower((string) $file->getClientOriginalExtension()); + if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) { + throw ValidationException::withMessages(['import' => '只能上传 Excel(xlsx / xls)或 CSV 文件。']); + } + + try { + $reader = PayablesWorkbookStreamReader::forFile((string) $file->getRealPath(), $extension); + $header = $this->locateHeader($reader); + } catch (ValidationException $exception) { + throw $exception; + } catch (Throwable) { + throw ValidationException::withMessages(['import' => '无法读取文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。']); + } + + $records = []; + $fields = array_keys(self::COLUMNS); + foreach ($reader->rows($header['sheet'], $header['row']) as $rowNumber => $cells) { + $values = []; + foreach ($fields as $index => $field) { + $value = trim((string) ($cells[$index] ?? '')); + $values[$field] = $value === '' ? null : $value; + } + + if (collect($values)->every(static fn (mixed $value): bool => $value === null)) { + continue; + } + + foreach (self::COLUMNS as $field => $label) { + if ($values[$field] === null) { + if (in_array($field, self::OPTIONAL_COLUMNS, true)) { + continue; + } + + throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」不能为空。"]); + } + + if (in_array($field, self::NUMERIC_COLUMNS, true)) { + $normalized = str_replace([' ', ',', ','], '', (string) $values[$field]); + if (preg_match('/^-?\d+(?:\.\d+)?$/', $normalized) !== 1) { + throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」必须是数字。"]); + } + $values[$field] = $normalized; + } + } + + if ($expectedSupplier !== null && $values['supplier'] !== $expectedSupplier) { + throw ValidationException::withMessages([ + 'import' => "第 {$rowNumber} 行:供应商必须填写「{$expectedSupplier}」,不能提交其他厂家的数据。", + ]); + } + + $records[] = $values; + if (count($records) > self::MAX_ROWS) { + throw ValidationException::withMessages(['import' => '单次最多上传 200,000 行,请拆分文件后重试。']); + } + } + + if ($records === []) { + throw ValidationException::withMessages(['import' => '表格里没有数据行,请按模板填写后重新上传。']); + } + + return $records; + } + + /** + * 找出模板表头:列名、顺序、起始列必须与模板完全一致。 + * + * @return array{sheet:int,row:int} + */ + private function locateHeader(PayablesWorkbookStreamReader $reader): array + { + $expected = array_values(self::COLUMNS); + + foreach (array_keys($reader->sheetNames()) as $sheetIndex) { + foreach ($reader->rows($sheetIndex, null, 20) as $rowNumber => $row) { + $labels = []; + foreach ($row as $columnIndex => $value) { + $label = $this->normalizeLabel((string) $value); + if ($label !== '') { + $labels[$columnIndex] = $label; + } + } + ksort($labels); + + if (array_values($labels) === array_map($this->normalizeLabel(...), $expected) && array_keys($labels) === range(0, count($expected) - 1)) { + return ['sheet' => $sheetIndex, 'row' => $rowNumber]; + } + } + } + + throw ValidationException::withMessages([ + 'import' => '表头与模板不一致:请下载最新模板填写,不要增删或修改列(列名、顺序必须与模板完全相同)。', + ]); + } + + /** 比较表头时忽略空白与全半角括号差异。 */ + private function normalizeLabel(string $label): string + { + $label = preg_replace('/\s+/u', '', trim($label)) ?? ''; + $label = str_replace(['(', ')'], ['(', ')'], $label); + + return mb_strtolower($label); + } +} diff --git a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php new file mode 100644 index 00000000..6c5c56cd --- /dev/null +++ b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php @@ -0,0 +1,174 @@ + */ + public function financeData(): array + { + $companyId = $this->companyId(); + $mine = $this->store->listRows( + PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation', + $companyId, + ); + + $months = []; + foreach ($mine as $row) { + $month = substr(trim((string) ($row['summary_month'] ?? '')), 0, 7); + if ($month !== '') { + $months[$month] = true; + } + } + $months = array_keys($months); + rsort($months); + + $month = trim((string) request()->query('month', '')); + if ($month !== '' && preg_match('/^\d{4}-\d{2}$/', $month) === 1) { + $mine = array_values(array_filter( + $mine, + static fn (array $row): bool => substr(trim((string) ($row['summary_month'] ?? '')), 0, 7) === $month, + )); + } else { + $month = ''; + } + + $suppliers = []; + foreach ($mine as $row) { + $name = trim((string) ($row['supplier'] ?? '')); + if ($name !== '') { + $suppliers[$name] = true; + } + } + foreach ($this->statements->uploadedSuppliers($companyId) as $name) { + $suppliers[$name] = true; + } + $supplierNames = array_keys($suppliers); + sort($supplierNames); + + $uploads = []; + foreach ($supplierNames as $name) { + $latest = $this->statements->uploads($name, $companyId, 1); + $uploads[$name] = $latest[0] ?? null; + } + + $selected = trim((string) request()->query('supplier', '')); + $detail = null; + if ($selected !== '' && in_array($selected, $supplierNames, true)) { + $detail = $this->detail($mine, $selected, $companyId); + } + + return [ + 'view' => 'business.pages.supplier-reconciliation.finance-data', + 'supplierNames' => $supplierNames, + 'uploads' => $uploads, + 'months' => $months, + 'month' => $month, + 'selectedSupplier' => $selected, + 'detail' => $detail, + 'statementTemplateUrl' => route('business.supplier-reconciliation.template'), + 'uploadUrl' => route('business.supplier-reconciliation.statement.upload'), + ]; + } + + /** @return array */ + public function supplierData(): array + { + $companyId = $this->companyId(); + $mine = $this->store->listRows( + PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation', + $companyId, + ); + + $suppliers = []; + foreach ($mine as $row) { + $name = trim((string) ($row['supplier'] ?? '')); + if ($name !== '') { + $suppliers[$name] = true; + } + } + foreach ($this->statements->uploadedSuppliers($companyId) as $name) { + $suppliers[$name] = true; + } + $supplierNames = array_keys($suppliers); + sort($supplierNames); + + $selected = trim((string) request()->query('supplier', '')); + if ($selected === '' || ! in_array($selected, $supplierNames, true)) { + $selected = $supplierNames[0] ?? ''; + } + + $page = max((int) request()->query('rows_page', 1), 1); + $statementRows = $selected !== '' ? $this->statements->rows($selected, $companyId) : []; + $returnRows = $selected !== '' ? $this->returnRows($selected, $companyId) : []; + + return [ + 'view' => 'business.pages.supplier-reconciliation.supplier-data', + 'supplierNames' => $supplierNames, + 'selectedSupplier' => $selected, + 'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [], + 'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE), + 'statementTotal' => count($statementRows), + 'returnRows' => array_slice($returnRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE), + 'returnTotal' => count($returnRows), + 'rowsPage' => $page, + 'perPage' => self::PER_PAGE, + 'statementTemplateUrl' => route('business.supplier-reconciliation.template'), + 'uploadUrl' => route('business.supplier-reconciliation.statement.upload'), + ]; + } + + /** @return array */ + private function detail(array $mine, string $supplier, int $companyId): array + { + $mineRows = array_values(array_filter( + $mine, + static fn (array $row): bool => trim((string) ($row['supplier'] ?? '')) === $supplier, + )); + $theirRows = $this->statements->rows($supplier, $companyId); + + $compared = $this->matcher->compare($mineRows, $theirRows); + $compared['mineRows'] = count($mineRows); + $compared['theirsRows'] = count($theirRows); + + return $compared; + } + + /** @return list> 该厂家的退货明细(我方数据,只读展示给厂家) */ + private function returnRows(string $supplier, int $companyId): array + { + $rows = $this->store->listRows( + PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation', + $companyId, + ); + + return array_values(array_filter( + $rows, + static fn (array $row): bool => trim((string) ($row['supplier'] ?? '')) === $supplier, + )); + } + + private function companyId(): int + { + return (int) (company_id() ?: 1); + } +} diff --git a/resources/templates/supplier-reconciliation/statement-template.xlsx b/resources/templates/supplier-reconciliation/statement-template.xlsx new file mode 100644 index 00000000..86f01598 Binary files /dev/null and b/resources/templates/supplier-reconciliation/statement-template.xlsx differ diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index 394b2de9..e3a18c36 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -590,6 +590,9 @@ @elseif (($section['slug'] ?? '') === 'financial-risk-control') {{-- 财务经营风险管控下已有多个页面,用目录内页签切换。 --}} @include('business.pages.partials.section-page-tabs', ['section' => $section]) + @elseif (($section['slug'] ?? '') === 'supplier-reconciliation') + {{-- 供应商对账:财务端数据 / 厂家端数据 两个页签。 --}} + @include('business.pages.partials.section-page-tabs', ['section' => $section]) @endif @if ($tabs->isNotEmpty()) diff --git a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php new file mode 100644 index 00000000..2e40221c --- /dev/null +++ b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php @@ -0,0 +1,160 @@ +
+ + + @if (is_string(session('success'))) +
{{ session('success') }}
+ @endif + @if ($errors->has('import')) + + @endif + +
+
+
+ + + +
+ + 下载对账单模板 +
+

+ 比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算; + 厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算; + 厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。 +

+
+ +
+

各厂家上传情况(实时)

+
+ + + + + + + + + + + @forelse ($supplierNames as $supplier) + @php $upload = $uploads[$supplier] ?? null; @endphp + + + + + + + @empty + + @endforelse + +
供应商最近上传时间上传行数比对
{{ $supplier }}{{ $upload['uploaded_at'] ?? '—' }}{{ $upload ? number_format((int) ($upload['rows'] ?? 0)) : '—' }} + 查看比对 +
还没有可对账的供应商:请先在「供应商应付(终版)→ 货款对账」导入我方结算数据。
+
+
+ + @if ($detail) +
+

{{ $selectedSupplier }} · 比对明细(我方 {{ number_format($detail['mineRows']) }} 行 / 厂家 {{ number_format($detail['theirsRows']) }} 行)

+
+
双方一致{{ $detail['summary']['match'] }}
+
厂家多(退回修改){{ $detail['summary']['supplierExtra'] }}
+
金额不一致(按低结算){{ $detail['summary']['mismatch'] }}
+
厂家少(按厂家结算){{ $detail['summary']['supplierMissing'] }}
+
我方金额合计{{ number_format($detail['summary']['mineAmount'], 2) }}
+
按口径结算合计{{ number_format($detail['summary']['settleAmount'], 2) }}
+
差异金额{{ number_format($detail['summary']['differenceAmount'], 2) }}
+
+
+ + + + + + + + + + + + + + + + @forelse ($detail['rows'] as $row) + @php + $tagClass = match ($row['type']) { + '厂家多' => 'is-extra', + '金额不一致' => 'is-mismatch', + '厂家少' => 'is-missing', + default => 'is-match', + }; + @endphp + + + + + + + + + + + + @empty + + @endforelse + +
比对结果条码单价我方数量厂家数量我方金额厂家金额结算口径结算金额
{{ $row['type'] }}{{ $row['barcode'] }}{{ number_format($row['unitPrice'], 2) }}{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}{{ $row['note'] }}{{ number_format($row['settleAmount'], 2) }}
该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。
+
+
+ @endif +
diff --git a/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php new file mode 100644 index 00000000..52a5c2a9 --- /dev/null +++ b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php @@ -0,0 +1,159 @@ +
+ + + @if (is_string(session('success'))) +
{{ session('success') }}
+ @endif + @if ($errors->has('import')) + + @endif + +
+

厂家对账单 · 上传与实时数据

+
+ + 下载对账单模板 +
+

+ 厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空,其余列必填; + 不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。 +

+ @if ($selectedSupplier !== '') +
+ @csrf + + + +
+ @else +
还没有可选的厂家:请先在「供应商应付(终版)→ 货款对账」导入我方结算数据,厂家按其中的供应商名称上传对账单。
+ @endif +
+ + @if ($selectedSupplier !== '') +
+

{{ $selectedSupplier }} · 上传记录

+
+ + + + + + @forelse ($uploads as $upload) + + + + + + @empty + + @endforelse + +
上传时间文件名行数
{{ $upload['uploaded_at'] ?? '—' }}{{ $upload['filename'] ?? '—' }}{{ number_format((int) ($upload['rows'] ?? 0)) }}
该厂家还没有上传过对账单。
+
+
+ +
+

{{ $selectedSupplier }} · 上传的对账数据(共 {{ number_format($statementTotal) }} 行)

+
+ + + + + + + + + + @forelse ($statementRows as $row) + + + + + + + + + + + + + @empty + + @endforelse + +
快递名称快递单号单据编号(采购单号)创建时间供应商条码规格名称数量单价金额
{{ $row['expressName'] ?? '—' }}{{ $row['expressNo'] ?? '—' }}{{ $row['purchaseNo'] ?? '—' }}{{ $row['createdAt'] ?? '—' }}{{ $row['supplier'] ?? '—' }}{{ $row['barcode'] ?? '—' }}{{ $row['skuName'] ?? '—' }}{{ $row['qty'] ?? '—' }}{{ $row['unitPrice'] ?? '—' }}{{ $row['amount'] ?? '—' }}
暂无上传数据。
+
+ @if ($statementTotal > $perPage) +
+ 第 {{ $rowsPage }} 页 / 共 {{ max((int) ceil($statementTotal / $perPage), 1) }} 页 + @if ($rowsPage > 1) + 上一页 + @endif + @if ($rowsPage * $perPage < $statementTotal) + 下一页 + @endif +
+ @endif +
+ +
+

{{ $selectedSupplier }} · 退货明细(我方数据,只读,共 {{ number_format($returnTotal) }} 行)

+
+ + + + + + + + + + @forelse ($returnRows as $row) + + + + + + + + + + + + + @empty + + @endforelse + +
月份发货时间明细供应商条形码商品名称发货量单价金额仓库名称快递单号
{{ $row['summary_month'] ?? '—' }}{{ $row['send_time'] ?? '—' }}{{ $row['supplier'] ?? '—' }}{{ $row['barcode'] ?? '—' }}{{ $row['sku_name'] ?? '—' }}{{ $row['qty'] ?? '—' }}{{ $row['unit_price'] ?? '—' }}{{ $row['amount'] ?? '—' }}{{ $row['warehouse'] ?? '—' }}{{ $row['express_no'] ?? '—' }}
该厂家暂无退货数据。
+
+
+ @endif +
diff --git a/routes/admin.php b/routes/admin.php index c364f8be..79681b7f 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Business\CourierSurchargeTypes; use App\Http\Controllers\Business\ProductPrices; use App\Http\Controllers\Business\SupplierPayableActions; use App\Http\Controllers\Business\PayablesFinalActions; +use App\Http\Controllers\Business\SupplierReconciliation as SupplierReconciliationActions; use App\Http\Controllers\Business\WithdrawalManagement; use Illuminate\Support\Facades\Route; use Modules\EcommerceFinance\Http\Controllers\AccountBills; @@ -385,6 +386,15 @@ foreach ($payablesFinalDatasets as $finalSection => $finalDatasets) { } } +// 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验) +Route::get('workspace/supplier-reconciliation/statement-template', [SupplierReconciliationActions::class, 'template']) + ->middleware('business.page:supplier-reconciliation,supplier-data') + ->name('business.supplier-reconciliation.template'); + +Route::post('workspace/supplier-reconciliation/statement', [SupplierReconciliationActions::class, 'upload']) + ->middleware(['business.page:supplier-reconciliation,supplier-data', 'import']) + ->name('business.supplier-reconciliation.statement.upload'); + Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds') ->middleware('business.page:refunds,non-jingxi-refund-data') ->name('business.refunds.non-jingxi-refund-data.export'); diff --git a/tests/Feature/Business/SupplierReconciliationTest.php b/tests/Feature/Business/SupplierReconciliationTest.php new file mode 100644 index 00000000..c23754b7 --- /dev/null +++ b/tests/Feature/Business/SupplierReconciliationTest.php @@ -0,0 +1,198 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } + + public function test_finance_page_renders_with_comparison_rules(): void + { + $this->seedStatement('晨丰', [ + $this->statementRow('ZSJ-1', '128.00', '2', '256.00'), + ]); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'supplier-reconciliation', + 'page' => 'finance-data', + 'supplier' => '晨丰', + ])) + ->assertOk() + ->assertSeeText('供应商对账') + ->assertSeeText('财务端数据') + ->assertSeeText('厂家端数据') + ->assertSeeText('厂家多') + ->assertSeeText('取金额低的一方结算') + ->assertSeeText('下载对账单模板'); + } + + public function test_supplier_page_shows_uploads_and_return_rows(): void + { + $this->seedStatement('晨丰', [ + $this->statementRow('ZSJ-1', '128.00', '2', '256.00'), + ]); + app(SupplierPayableOverrideStore::class)->save( + 'final:supplier:return-reconciliation', + 'line:000001', + ['summary_month' => '2026-09', 'send_time' => '2026-09-30', 'supplier' => '晨丰', 'barcode' => 'ZSJ-1', + 'sku_name' => '款式十一', 'qty' => '1', 'unit_price' => '128', 'amount' => '128'], + (int) $this->company->id, + null, + ); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'supplier-reconciliation', + 'page' => 'supplier-data', + 'supplier' => '晨丰', + ])) + ->assertOk() + ->assertSeeText('厂家对账单 · 上传与实时数据') + ->assertSeeText('上传记录') + ->assertSeeText('退货明细') + ->assertSeeText('款式十一'); + } + + public function test_statement_template_downloads_the_bundled_file(): void + { + $this->loginAs() + ->get(route('business.supplier-reconciliation.template')) + ->assertOk() + ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + } + + public function test_upload_accepts_a_strictly_formatted_statement(): void + { + $file = UploadedFile::fake()->createWithContent( + 'statement.csv', + "\xEF\xBB\xBF" . self::HEADER . "\n" + . ",,编号-1,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n", + ); + + $this->loginAs() + ->post(route('business.supplier-reconciliation.statement.upload'), [ + 'supplier' => '晨丰', + 'import' => $file, + ]) + ->assertStatus(302) + ->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。'); + + $rows = app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id); + $this->assertCount(1, $rows); + $this->assertSame('ZSJ-1', $rows[0]['barcode']); + $this->assertCount(1, app(SupplierStatementStore::class)->uploads('晨丰', (int) $this->company->id)); + } + + public function test_upload_rejects_statements_that_do_not_match_the_template(): void + { + $file = UploadedFile::fake()->createWithContent( + 'statement.csv', + "\xEF\xBB\xBF快递名称,快递单号,单据编号,创建时间,供应商,条码,规格名称,数量,单价,金额\n" + . ",,编号-1,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n", + ); + + /** @var ValidationException $exception */ + $exception = null; + try { + app(SupplierStatementTemplate::class)->parse($file, '晨丰'); + } catch (ValidationException $caught) { + $exception = $caught; + } + + $this->assertNotNull($exception, '表头不一致必须整份失败。'); + $this->assertStringContainsString('表头与模板不一致', $exception->errors()['import'][0]); + } + + public function test_template_validation_requires_filled_numeric_and_supplier_columns(): void + { + $files = [ + 'missing-required.csv' => [",,,2026-09-30,晨丰,ZSJ-1,规格甲,,128.00,256.00", '「数量」不能为空'], + 'not-numeric.csv' => [",,,2026-09-30,晨丰,ZSJ-1,规格甲,两件,128.00,256.00", '「数量」必须是数字'], + 'other-supplier.csv' => [",,,2026-09-30,鸣飞家具,ZSJ-1,规格甲,2,128.00,256.00", '不能提交其他厂家的数据'], + ]; + + foreach ($files as $name => [$row, $expectedMessage]) { + $file = UploadedFile::fake()->createWithContent( + $name, + "\xEF\xBB\xBF" . self::HEADER . "\n" . $row . "\n", + ); + + try { + app(SupplierStatementTemplate::class)->parse($file, '晨丰'); + $this->fail("{$name} 应当校验失败。"); + } catch (ValidationException $exception) { + $this->assertStringContainsString($expectedMessage, $exception->errors()['import'][0]); + } + } + } + + public function test_matcher_applies_the_agreed_settlement_rules(): void + { + $mine = [ + ['skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], // 一致 + ['skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], // 金额不一致(厂家低) + ['skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], // 厂家少 + ]; + $theirs = [ + ['barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], + ['barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'], + ['barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'], // 厂家多 + ]; + + $result = app(SupplierStatementMatcher::class)->compare($mine, $theirs); + + $this->assertSame(1, $result['summary']['match']); + $this->assertSame(1, $result['summary']['mismatch']); + $this->assertSame(1, $result['summary']['supplierMissing']); + $this->assertSame(1, $result['summary']['supplierExtra']); + $this->assertSame(330.0, $result['summary']['mineAmount']); + $this->assertSame(250.0, $result['summary']['settleAmount']); + $this->assertSame(80.0, $result['summary']['differenceAmount']); + + $byBarcode = collect($result['rows'])->keyBy('barcode'); + $this->assertSame(50.0, $byBarcode['B-1']['settleAmount']); + $this->assertSame(0.0, $byBarcode['C-1']['settleAmount']); + $this->assertSame(0.0, $byBarcode['D-1']['settleAmount']); + } + + private function statementRow(string $barcode, string $unitPrice, string $qty, string $amount): array + { + return [ + 'expressName' => null, + 'expressNo' => null, + 'purchaseNo' => '编号-' . $barcode, + 'createdAt' => '2026-09-30', + 'supplier' => '晨丰', + 'barcode' => $barcode, + 'skuName' => '规格甲', + 'qty' => $qty, + 'unitPrice' => $unitPrice, + 'amount' => $amount, + ]; + } + + /** @param list> $rows */ + private function seedStatement(string $supplier, array $rows): void + { + app(SupplierStatementStore::class)->save($supplier, $rows, 'statement.csv', (int) $this->company->id, null); + } +}