供应商对账:应付款项下新增对账页面(财务端数据 / 厂家端数据) (#364)

新增供应商对账目录:财务端实时上传情况与行级差异(条码+单价匹配,四类口径),厂家端严格按模板上传(表头不一致/必填缺失/非数字/串厂一律失败);复用终版存储与流式读取。
This commit is contained in:
renshen 2026-10-17 08:49:17 +08:00
parent 82c79dadc3
commit 87eaf48c2c
16 changed files with 1174 additions and 1 deletions

View File

@ -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,

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
) {
$this->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);
}
}

View File

@ -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 === []) {

View File

@ -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');

View File

@ -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' => '快递应付(终版)',

View File

@ -174,6 +174,56 @@ class SupplierPayableOverrideStore
return $this->connection()->transaction($callback);
}
/**
* 整份替换某个数据集(厂家对账单:以最新一版为准),不写逐行审计事件。
*
* @param array<string,array<string,mixed>> $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<string> 某个前缀下已有的数据集名 */
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
{

View File

@ -0,0 +1,118 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 厂家对账单 vs 我方结算数据:
* - 行键 = 条码 + 单价(同一把钥匙的多行先合并:数量、金额相加);
* - 四类结果:一致 / 厂家多(不结算、退回)/ 金额不一致(按金额低的一方结算)/ 厂家少(按厂家数据结算,即不付款)。
*/
class SupplierStatementMatcher
{
public const TYPE_MATCH = '一致';
public const TYPE_SUPPLIER_EXTRA = '厂家多';
public const TYPE_MISMATCH = '金额不一致';
public const TYPE_SUPPLIER_MISSING = '厂家少';
/**
* @param list<array<string,mixed>> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $theirs 厂家对账单(barcode/unitPrice/qty/amount)
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
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<array<string,mixed>> $rows
* @return array<string,array{barcode:string,unitPrice:float,qty:float,amount:float}>
*/
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;
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 厂家对账单的存放:整份替换(以最新一版为准)+ 上传记录。
* 数据放在与「终版」目录同一张按表数据表里,数据集键带 statement: 前缀。
*/
class SupplierStatementStore
{
public const PREFIX = 'statement:';
public const LOG_PREFIX = 'statement-log:';
public function __construct(
private readonly SupplierPayableOverrideStore $store,
) {
}
/** 保存厂家对账单(整份替换)并记录一次上传。 */
public function save(string $supplier, array $records, string $filename, int $companyId, ?int $userId): int
{
$rows = [];
foreach ($records as $index => $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<array<string,mixed>> 最新的对账单行 */
public function rows(string $supplier, int $companyId): array
{
return $this->store->listRows(self::PREFIX . $supplier, $companyId);
}
/** @return list<array<string,mixed>> 最近的上传记录(新到旧) */
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<string> 已经上传过对账单的厂家名称 */
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),
));
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Throwable;
/**
* 厂家对账单模板:列头、留空规则与严格校验。
* 按业务要求:不按模板形式上传的一律整份失败(表头列名、顺序、列数必须完全一致)。
*/
class SupplierStatementTemplate
{
/** 模板列(顺序固定):前 3 列允许留空,其余必填。 */
public const COLUMNS = [
'expressName' => '快递名称',
'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<array<string,mixed>> 模块内部键的记录(条码/单价/数量/金额等)
*/
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);
}
}

View File

@ -0,0 +1,174 @@
<?php
namespace Modules\EcommerceFinance\View;
use App\Http\Controllers\Business\PayablesFinalActions;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
/**
* 「供应商对账」页面数据:
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
*/
class SupplierReconciliationPageData
{
private const PER_PAGE = 100;
public function __construct(
private readonly SupplierPayableOverrideStore $store,
private readonly SupplierStatementStore $statements,
private readonly SupplierStatementMatcher $matcher,
) {
}
/** @return array<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<array<string,mixed>> 该厂家的退货明细(我方数据,只读展示给厂家) */
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);
}
}

View File

@ -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())

View File

@ -0,0 +1,160 @@
<div class="supplier-reconciliation-page">
<style>
.supplier-reconciliation-page { display: grid; gap: 16px; }
.supplier-reconciliation-page .sr-card { padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: var(--vip-surface, #fff); display: grid; gap: 12px; }
.supplier-reconciliation-page h2 { margin: 0; font-size: .875rem; font-weight: 600; color: var(--vip-content-ink, #171717); }
.supplier-reconciliation-page .sr-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
.supplier-reconciliation-page .sr-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; margin: 0; }
.supplier-reconciliation-page .sr-field { display: grid; gap: 4px; }
.supplier-reconciliation-page .sr-field span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
.supplier-reconciliation-page select,
.supplier-reconciliation-page input[type="search"] { height: 36px; min-width: 12rem; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; }
.supplier-reconciliation-page .sr-button { display: inline-flex; height: 36px; align-items: center; gap: 6px; padding: 0 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; }
.supplier-reconciliation-page .sr-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
.supplier-reconciliation-page .sr-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; }
.supplier-reconciliation-page .sr-scroll { overflow-x: auto; }
.supplier-reconciliation-page table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: .8125rem; }
.supplier-reconciliation-page th { padding: 8px 10px; background: var(--vip-content-accent-soft, #F7F0DE); border-bottom: 1px solid var(--vip-border, #D8D2C7); color: #5B5343; font-size: .75rem; font-weight: 600; text-align: left; white-space: nowrap; }
.supplier-reconciliation-page td { padding: 8px 10px; border-bottom: 1px solid #EFEAE0; vertical-align: middle; }
.supplier-reconciliation-page td.is-num { text-align: right; font-variant-numeric: tabular-nums; }
.supplier-reconciliation-page .sr-muted { color: var(--vip-content-muted, #595959); }
.supplier-reconciliation-page .sr-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; font-size: .75rem; white-space: nowrap; }
.supplier-reconciliation-page .sr-tag.is-match { background: #ECF4EC; color: #3F7A46; }
.supplier-reconciliation-page .sr-tag.is-extra { background: #FDF1E2; color: #B4650A; }
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
.supplier-reconciliation-page .sr-tag.is-missing { background: #EAF2F9; color: #2F6FA8; }
.supplier-reconciliation-page tr.is-extra td,
.supplier-reconciliation-page tr.is-mismatch td { background: #FFFDF7; }
.supplier-reconciliation-page .sr-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; }
.supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; }
.supplier-reconciliation-page .sr-summary-cell span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-reconciliation-page .sr-empty { padding: 28px; border: 1px dashed var(--vip-border, #D8D2C7); border-radius: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; text-align: center; }
</style>
@if (is_string(session('success')))
<div class="biz-alert" role="status">{{ session('success') }}</div>
@endif
@if ($errors->has('import'))
<div class="biz-alert biz-alert-error" role="alert">{{ $errors->first('import') }}</div>
@endif
<div class="sr-card">
<form method="GET" class="sr-toolbar" aria-label="供应商对账筛选">
<div class="sr-filters">
<label class="sr-field"><span>月份</span>
<select name="month" onchange="this.form.submit()">
<option value="">全部月份</option>
@foreach ($months as $optionMonth)
<option value="{{ $optionMonth }}" @selected($month === $optionMonth)>{{ $optionMonth }}</option>
@endforeach
</select>
</label>
<label class="sr-field"><span>供应商</span>
<select name="supplier" onchange="this.form.submit()">
<option value="">请选择厂家</option>
@foreach ($supplierNames as $supplier)
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
@endforeach
</select>
</label>
<button type="submit" class="sr-button is-primary">查询</button>
</div>
<span style="flex:1 1 auto"></span>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
</form>
<p class="sr-hint">
比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算;
厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。
</p>
</div>
<div class="sr-card">
<h2>各厂家上传情况(实时)</h2>
<div class="sr-scroll">
<table>
<thead>
<tr>
<th scope="col">供应商</th>
<th scope="col">最近上传时间</th>
<th scope="col">上传行数</th>
<th scope="col">比对</th>
</tr>
</thead>
<tbody>
@forelse ($supplierNames as $supplier)
@php $upload = $uploads[$supplier] ?? null; @endphp
<tr>
<td>{{ $supplier }}</td>
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
<td class="is-num">{{ $upload ? number_format((int) ($upload['rows'] ?? 0)) : '—' }}</td>
<td>
<a class="sr-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'month' => $month, 'supplier' => $supplier]) }}">查看比对</a>
</td>
</tr>
@empty
<tr><td colspan="4"><div class="sr-empty">还没有可对账的供应商:请先在「供应商应付(终版)→ 货款对账」导入我方结算数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if ($detail)
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 比对明细(我方 {{ number_format($detail['mineRows']) }} 行 / 厂家 {{ number_format($detail['theirsRows']) }} 行)</h2>
<div class="sr-summary">
<div class="sr-summary-cell"><span>双方一致</span><b>{{ $detail['summary']['match'] }}</b></div>
<div class="sr-summary-cell"><span>厂家多(退回修改)</span><b>{{ $detail['summary']['supplierExtra'] }}</b></div>
<div class="sr-summary-cell"><span>金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
<div class="sr-summary-cell"><span>我方金额合计</span><b>{{ number_format($detail['summary']['mineAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>按口径结算合计</span><b>{{ number_format($detail['summary']['settleAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>差异金额</span><b>{{ number_format($detail['summary']['differenceAmount'], 2) }}</b></div>
</div>
<div class="sr-scroll">
<table>
<thead>
<tr>
<th scope="col">比对结果</th>
<th scope="col">条码</th>
<th scope="col">单价</th>
<th scope="col">我方数量</th>
<th scope="col">厂家数量</th>
<th scope="col">我方金额</th>
<th scope="col">厂家金额</th>
<th scope="col">结算口径</th>
<th scope="col">结算金额</th>
</tr>
</thead>
<tbody>
@forelse ($detail['rows'] as $row)
@php
$tagClass = match ($row['type']) {
'厂家多' => 'is-extra',
'金额不一致' => 'is-mismatch',
'厂家少' => 'is-missing',
default => 'is-match',
};
@endphp
<tr class="{{ $tagClass }}">
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
<td>{{ $row['barcode'] }}</td>
<td class="is-num">{{ number_format($row['unitPrice'], 2) }}</td>
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
<td class="sr-muted">{{ $row['note'] }}</td>
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
</tr>
@empty
<tr><td colspan="9"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,159 @@
<div class="supplier-reconciliation-page">
<style>
.supplier-reconciliation-page { display: grid; gap: 16px; }
.supplier-reconciliation-page .sr-card { padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: var(--vip-surface, #fff); display: grid; gap: 12px; }
.supplier-reconciliation-page h2 { margin: 0; font-size: .875rem; font-weight: 600; color: var(--vip-content-ink, #171717); }
.supplier-reconciliation-page .sr-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
.supplier-reconciliation-page .sr-field { display: grid; gap: 4px; }
.supplier-reconciliation-page .sr-field span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
.supplier-reconciliation-page select { height: 36px; min-width: 12rem; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; }
.supplier-reconciliation-page .sr-button { display: inline-flex; height: 36px; align-items: center; gap: 6px; padding: 0 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; }
.supplier-reconciliation-page .sr-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
.supplier-reconciliation-page .sr-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; }
.supplier-reconciliation-page .sr-scroll { overflow-x: auto; }
.supplier-reconciliation-page table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: .8125rem; }
.supplier-reconciliation-page th { padding: 8px 10px; background: var(--vip-content-accent-soft, #F7F0DE); border-bottom: 1px solid var(--vip-border, #D8D2C7); color: #5B5343; font-size: .75rem; font-weight: 600; text-align: left; white-space: nowrap; }
.supplier-reconciliation-page td { padding: 8px 10px; border-bottom: 1px solid #EFEAE0; }
.supplier-reconciliation-page td.is-num { text-align: right; font-variant-numeric: tabular-nums; }
.supplier-reconciliation-page .sr-muted { color: var(--vip-content-muted, #595959); }
.supplier-reconciliation-page .sr-empty { padding: 28px; border: 1px dashed var(--vip-border, #D8D2C7); border-radius: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; text-align: center; }
.supplier-reconciliation-page .sr-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.supplier-reconciliation-page .sr-pagination { display: flex; align-items: center; gap: 12px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
</style>
@if (is_string(session('success')))
<div class="biz-alert" role="status">{{ session('success') }}</div>
@endif
@if ($errors->has('import'))
<div class="biz-alert biz-alert-error" role="alert">{{ $errors->first('import') }}</div>
@endif
<div class="sr-card">
<h2>厂家对账单 · 上传与实时数据</h2>
<form method="GET" class="sr-toolbar" aria-label="选择厂家">
<label class="sr-field"><span>厂家</span>
<select name="supplier" onchange="this.form.submit()">
@foreach ($supplierNames as $supplier)
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
@endforeach
</select>
</label>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
</form>
<p class="sr-hint">
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空,其余列必填;
不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。
</p>
@if ($selectedSupplier !== '')
<form method="POST" action="{{ $uploadUrl }}" enctype="multipart/form-data" class="sr-upload">
@csrf
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<input type="file" name="import" accept=".xlsx,.xls,.csv" required>
<button type="submit" class="sr-button is-primary">上传 {{ $selectedSupplier }} 的对账单</button>
</form>
@else
<div class="sr-empty">还没有可选的厂家:请先在「供应商应付(终版)→ 货款对账」导入我方结算数据,厂家按其中的供应商名称上传对账单。</div>
@endif
</div>
@if ($selectedSupplier !== '')
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 上传记录</h2>
<div class="sr-scroll">
<table>
<thead>
<tr><th scope="col">上传时间</th><th scope="col">文件名</th><th scope="col">行数</th></tr>
</thead>
<tbody>
@forelse ($uploads as $upload)
<tr>
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
<td>{{ $upload['filename'] ?? '—' }}</td>
<td class="is-num">{{ number_format((int) ($upload['rows'] ?? 0)) }}</td>
</tr>
@empty
<tr><td colspan="3"><div class="sr-empty">该厂家还没有上传过对账单。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 上传的对账数据(共 {{ number_format($statementTotal) }} 行)</h2>
<div class="sr-scroll">
<table>
<thead>
<tr>
<th scope="col">快递名称</th><th scope="col">快递单号</th><th scope="col">单据编号(采购单号)</th>
<th scope="col">创建时间</th><th scope="col">供应商</th><th scope="col">条码</th>
<th scope="col">规格名称</th><th scope="col">数量</th><th scope="col">单价</th><th scope="col">金额</th>
</tr>
</thead>
<tbody>
@forelse ($statementRows as $row)
<tr>
<td class="sr-muted">{{ $row['expressName'] ?? '—' }}</td>
<td class="sr-muted">{{ $row['expressNo'] ?? '—' }}</td>
<td class="sr-muted">{{ $row['purchaseNo'] ?? '—' }}</td>
<td>{{ $row['createdAt'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
<td>{{ $row['skuName'] ?? '—' }}</td>
<td class="is-num">{{ $row['qty'] ?? '—' }}</td>
<td class="is-num">{{ $row['unitPrice'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
</tr>
@empty
<tr><td colspan="10"><div class="sr-empty">暂无上传数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($statementTotal > $perPage)
<div class="sr-pagination">
<span>第 {{ $rowsPage }} 页 / 共 {{ max((int) ceil($statementTotal / $perPage), 1) }} 页</span>
@if ($rowsPage > 1)
<a class="sr-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier, 'rows_page' => $rowsPage - 1]) }}">上一页</a>
@endif
@if ($rowsPage * $perPage < $statementTotal)
<a class="sr-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier, 'rows_page' => $rowsPage + 1]) }}">下一页</a>
@endif
</div>
@endif
</div>
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 退货明细(我方数据,只读,共 {{ number_format($returnTotal) }} 行)</h2>
<div class="sr-scroll">
<table>
<thead>
<tr>
<th scope="col">月份</th><th scope="col">发货时间</th><th scope="col">明细供应商</th><th scope="col">条形码</th>
<th scope="col">商品名称</th><th scope="col">发货量</th><th scope="col">单价</th><th scope="col">金额</th>
<th scope="col">仓库名称</th><th scope="col">快递单号</th>
</tr>
</thead>
<tbody>
@forelse ($returnRows as $row)
<tr>
<td>{{ $row['summary_month'] ?? '—' }}</td>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
<td>{{ $row['sku_name'] ?? '—' }}</td>
<td class="is-num">{{ $row['qty'] ?? '—' }}</td>
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
<td>{{ $row['warehouse'] ?? '—' }}</td>
<td>{{ $row['express_no'] ?? '—' }}</td>
</tr>
@empty
<tr><td colspan="10"><div class="sr-empty">该厂家暂无退货数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
</div>

View File

@ -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');

View File

@ -0,0 +1,198 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Tests\Feature\FeatureTestCase;
class SupplierReconciliationTest extends FeatureTestCase
{
private const HEADER = '快递名称,快递单号,单据编号(采购单号),创建时间,供应商,条码,规格名称,数量,单价,金额';
protected function setUp(): void
{
parent::setUp();
$this->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<array<string,mixed>> $rows */
private function seedStatement(string $supplier, array $rows): void
{
app(SupplierStatementStore::class)->save($supplier, $rows, 'statement.csv', (int) $this->company->id, null);
}
}