供应商对账:应付款项下新增对账页面(财务端数据 / 厂家端数据)
新增「供应商对账」目录:财务端按供应商查看实时上传情况与行级差异(条码+单价 匹配:一致/厂家多/金额不一致/厂家少,口径分别为正常结算/不结算退回/按金额低结算/按厂家结算);厂家端上传严格按对账单模板校验(表头列名与顺序必须一致、快递名称/快递单号/单据编号可空、其余必填、数值列必须是数字、供应商必须与厂家一致,不合规整份失败并提示行号);复用终版按表数据存储与流式读取能力,模板随包发布。
This commit is contained in:
parent
82c79dadc3
commit
228181d5f1
@ -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,
|
||||
|
||||
61
app/Http/Controllers/Business/SupplierReconciliation.php
Normal file
61
app/Http/Controllers/Business/SupplierReconciliation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 === []) {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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' => '快递应付(终版)',
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
118
modules/EcommerceFinance/Services/SupplierStatementMatcher.php
Normal file
118
modules/EcommerceFinance/Services/SupplierStatementMatcher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
61
modules/EcommerceFinance/Services/SupplierStatementStore.php
Normal file
61
modules/EcommerceFinance/Services/SupplierStatementStore.php
Normal 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),
|
||||
));
|
||||
}
|
||||
}
|
||||
157
modules/EcommerceFinance/Services/SupplierStatementTemplate.php
Normal file
157
modules/EcommerceFinance/Services/SupplierStatementTemplate.php
Normal 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);
|
||||
}
|
||||
}
|
||||
174
modules/EcommerceFinance/View/SupplierReconciliationPageData.php
Normal file
174
modules/EcommerceFinance/View/SupplierReconciliationPageData.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -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())
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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');
|
||||
|
||||
198
tests/Feature/Business/SupplierReconciliationTest.php
Normal file
198
tests/Feature/Business/SupplierReconciliationTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user