CaiWuXiTong/modules/EcommerceFinance/View/SupplierReconciliationPageData.php
renshen 2a2df7451e 供应商对账:退货表格改为十条并固定表头 (#403)
退货表格调整为每页十条,并增加固定高度滚动区和吸顶表头。
2026-10-20 11:21:22 +08:00

588 lines
22 KiB
PHP

<?php
namespace Modules\EcommerceFinance\View;
use App\Http\Controllers\Business\PayablesFinalActions;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
/**
* 「供应商对账」页面数据:
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
*/
class SupplierReconciliationPageData
{
/** 厂家上传的对账数据表:每页行数(短表 + 翻页)。 */
private const PER_PAGE = 15;
/** 退货明细行较高:每页 10 行,并在页面内固定表头滚动查看。 */
private const RETURN_PER_PAGE = 10;
public function __construct(
private readonly SupplierPayableOverrideStore $store,
private readonly SupplierStatementStore $statements,
private readonly SupplierStatementMatcher $matcher,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierReturnAccounting $returnAccounting,
) {
}
/** @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 = '';
}
$reconcilers = [];
foreach ($mine as $row) {
$name = trim((string) ($row['reconciler'] ?? ''));
if ($name !== '') {
$reconcilers[$name] = true;
}
}
$reconcilers = array_keys($reconcilers);
sort($reconcilers);
$reconciler = trim((string) request()->query('reconciler', ''));
if ($reconciler !== '') {
$mine = array_values(array_filter(
$mine,
static fn (array $row): bool => trim((string) ($row['reconciler'] ?? '')) === $reconciler,
));
}
$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;
}
foreach ($this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
) as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($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;
}
// 选中厂家时页面切换成「比对明细」视图(列表不再渲染,避免 100+ 家时往下翻)。
$selected = trim((string) request()->query('supplier', ''));
$detail = null;
if ($selected !== '' && in_array($selected, $supplierNames, true)) {
$detail = $this->detail($mine, $selected, $companyId);
} else {
$selected = '';
}
return [
'view' => 'business.pages.supplier-reconciliation.finance-data',
'supplierNames' => $supplierNames,
'uploads' => $uploads,
'months' => $months,
'month' => $month,
'reconcilers' => $reconcilers,
'reconciler' => $reconciler,
'selectedSupplier' => $selected,
'detail' => $detail,
'financeReturnPanel' => $detail !== null
? $this->financeReturnPanel($selected, $companyId, $month, $reconciler)
: null,
'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null,
'listUrl' => route('business.pages.show', array_filter([
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'month' => $month,
'reconciler' => $reconciler,
], static fn (mixed $value): bool => $value !== '' && $value !== null)),
'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;
}
foreach ($this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
) as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
$suppliers[$name] = true;
}
}
$supplierNames = array_keys($suppliers);
sort($supplierNames);
$supplierQuery = trim((string) request()->query('supplier', ''));
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
$supplierNotFound = $supplierQuery !== '' && $selected === '';
if ($supplierQuery === '') {
$selected = $supplierNames[0] ?? '';
}
$page = max((int) request()->query('rows_page', 1), 1);
$statementRows = $selected !== '' ? $this->statements->rows($selected, $companyId) : [];
$returnData = $selected !== ''
? $this->returnPanelData($selected, $companyId, request()->query())
: ['returnPanel' => null, 'returnWarehouses' => []];
return [
'view' => 'business.pages.supplier-reconciliation.supplier-data',
'supplierNames' => $supplierNames,
'supplierQuery' => $supplierQuery,
'supplierNotFound' => $supplierNotFound,
'selectedSupplier' => $selected,
'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [],
'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
'statementTotal' => count($statementRows),
'returnPanel' => $returnData['returnPanel'],
'returnWarehouses' => $returnData['returnWarehouses'],
'rowsPage' => $page,
'perPage' => self::PER_PAGE,
'statementTemplateUrl' => route('business.supplier-reconciliation.template'),
'uploadUrl' => route('business.supplier-reconciliation.statement.upload'),
'returnConfirmUrl' => route('business.supplier-reconciliation.return.confirm'),
'returnDisputeUrl' => route('business.supplier-reconciliation.return.dispute'),
'returnConfirmAllUrl' => route('business.supplier-reconciliation.return.confirm-all'),
];
}
/**
* 厂家门户只读取当前登录联系人绑定的供应商数据。
*
* @return array<string,mixed>
*/
public function portalData(string $supplier, int $companyId): array
{
$page = max((int) request()->query('rows_page', 1), 1);
$statementRows = $this->statements->rows($supplier, $companyId);
$returnData = $this->returnPanelData($supplier, $companyId, request()->query());
return [
'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
'statementTotal' => count($statementRows),
'uploads' => $this->statements->uploads($supplier, $companyId),
'returnPanel' => $returnData['returnPanel'],
'returnWarehouses' => $returnData['returnWarehouses'],
'rowsPage' => $page,
'perPage' => self::PER_PAGE,
];
}
/**
* 按页面当前月份/对账人筛选重新计算财务端比对结果,供差异推送使用。
*
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function financeDetailForFilters(string $supplier, int $companyId, array $input): array
{
$mine = $this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation',
$companyId,
);
$month = $this->filterText($input['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,
));
}
$reconciler = $this->filterText($input['reconciler'] ?? '');
if ($reconciler !== '') {
$mine = array_values(array_filter(
$mine,
static fn (array $row): bool => trim((string) ($row['reconciler'] ?? '')) === $reconciler,
));
}
return $this->detail($mine, $supplier, $companyId);
}
/**
* 财务端比对页的退货面板:与厂家端使用同一终版数据集,并保留月份/对账人筛选。
*
* @return array<string,mixed>
*/
private function financeReturnPanel(
string $supplier,
int $companyId,
string $month,
string $reconciler,
): array {
$allRows = $this->returnRows($supplier, $companyId);
$statuses = $this->returnConfirmations->statuses($supplier, $companyId);
$rows = [];
foreach ($allRows as $row) {
$rowMonth = substr(trim((string) ($row['send_time'] ?? '')), 0, 7);
if ($rowMonth === '') {
$rowMonth = substr(trim((string) ($row['summary_month'] ?? '')), 0, 7);
}
if ($month !== '' && $rowMonth !== $month) {
continue;
}
if ($reconciler !== '' && trim((string) ($row['reconciler'] ?? '')) !== $reconciler) {
continue;
}
$row['_status'] = $statuses[(string) ($row['_action_key'] ?? '')] ?? '';
$rows[] = $row;
}
return $this->returnConfirmationPanel(
$supplier,
$rows,
$this->returnFilters([]),
count($allRows),
'finance_return_page',
);
}
/**
* @param array<string,mixed> $input
* @return array{returnPanel:array<string,mixed>,returnWarehouses:list<string>}
*/
private function returnPanelData(string $supplier, int $companyId, array $input): array
{
$allReturnRows = $this->returnRows($supplier, $companyId);
$returnWarehouses = [];
foreach ($allReturnRows as $row) {
$warehouse = $this->filterText($row['warehouse'] ?? '');
if ($warehouse !== '') {
$returnWarehouses[$warehouse] = true;
}
}
$returnWarehouses = array_keys($returnWarehouses);
sort($returnWarehouses, SORT_NATURAL | SORT_FLAG_CASE);
$filters = $this->returnFilters($input);
$returnRows = $this->filterReturnRows(
$allReturnRows,
$filters,
$this->returnConfirmations->statuses($supplier, $companyId),
);
return [
'returnPanel' => $this->returnConfirmationPanel($supplier, $returnRows, $filters, count($allReturnRows)),
'returnWarehouses' => $returnWarehouses,
];
}
/**
* 退货扣减确认面板:按筛选结果汇总并分页,保留导入顺序。
*
* @param list<array<string,mixed>> $returnRows
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
* @return array<string,mixed>
*/
private function returnConfirmationPanel(
string $supplier,
array $returnRows,
array $filters,
int $allRows,
string $pageQueryKey = 'return_page',
): array
{
$total = 0.0;
$confirmedAmount = 0.0;
$disputedAmount = 0.0;
$returnQuantity = 0.0;
$confirmedQuantity = 0.0;
$disputedQuantity = 0.0;
$disputedCount = 0;
$confirmedCount = 0;
$rows = [];
foreach ($returnRows as $row) {
$row = $this->returnAccounting->enrich($row);
$key = (string) ($row['_action_key'] ?? '');
$deduction = $row['_accounting_target'] === '不记账'
? 0.0
: (float) $row['_supplier_amount'];
$quantity = (float) $row['_supplier_qty'];
$status = (string) ($row['_status'] ?? '');
$total += $deduction;
$returnQuantity += $quantity;
if ($status === SupplierReturnConfirmation::STATUS_CONFIRMED) {
$confirmedAmount += $deduction;
$confirmedQuantity += $quantity;
$confirmedCount++;
} elseif ($status === SupplierReturnConfirmation::STATUS_DISPUTED) {
$disputedAmount += $deduction;
$disputedQuantity += $quantity;
$disputedCount++;
}
$rows[] = ['_key' => $key, 'status' => $status, 'deduction' => $deduction] + $row;
}
// 全部退货行按导入顺序分页展示(每页 10 行),不做金额排序。
$perPage = self::RETURN_PER_PAGE;
$pages = max((int) ceil(count($rows) / $perPage), 1);
$page = min(max((int) request()->query($pageQueryKey, 1), 1), $pages);
$filterQuery = $this->returnFilterQuery($filters);
return [
'supplier' => $supplier,
'rows' => array_slice($rows, ($page - 1) * $perPage, $perPage),
'page' => $page,
'pages' => $pages,
'per_page' => $perPage,
'total_rows' => count($returnRows),
'all_rows' => $allRows,
'total_amount' => round($total, 2),
'return_qty' => round($returnQuantity, 2),
'confirmed_qty' => round($confirmedQuantity, 2),
'disputed_qty' => round($disputedQuantity, 2),
'pending_qty' => round(max($returnQuantity - $confirmedQuantity - $disputedQuantity, 0), 2),
'pending_amount' => round(max($total - $confirmedAmount - $disputedAmount, 0), 2),
'confirmed_count' => $confirmedCount,
'confirmed_amount' => round($confirmedAmount, 2),
'disputed_count' => $disputedCount,
'disputed_amount' => round($disputedAmount, 2),
'accounting_summary' => $this->returnAccounting->summarize($returnRows),
'filters' => $filters,
'filter_query' => $filterQuery,
'is_filtered' => $filterQuery !== [],
];
}
/** @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>> 该厂家的退货明细(我方数据,展示给厂家确认扣减) */
public 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,
));
}
/**
* 厂家端当前查询条件下的全部退货行(用于导出,不分页)。
*
* @param array<string,mixed> $input
* @return list<array<string,mixed>>
*/
public function returnRowsForFilters(string $supplier, int $companyId, array $input): array
{
return $this->filterReturnRows(
$this->returnRows($supplier, $companyId),
$this->returnFilters($input),
$this->returnConfirmations->statuses($supplier, $companyId),
);
}
/**
* 解析厂家退货查询条件,空值会被忽略。
*
* @param array<string,mixed> $input
* @return array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string}
*/
public function returnFilters(array $input): array
{
$dateFrom = $this->normalizeFilterDate($input['return_date_from'] ?? '');
$dateTo = $this->normalizeFilterDate($input['return_date_to'] ?? '');
if ($dateFrom !== '' && $dateTo !== '' && $dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
}
$status = $this->filterText($input['return_status'] ?? '');
if (! in_array($status, ['pending', SupplierReturnConfirmation::STATUS_CONFIRMED, SupplierReturnConfirmation::STATUS_DISPUTED], true)) {
$status = '';
}
return [
'keyword' => $this->filterText($input['return_keyword'] ?? ''),
'date_from' => $dateFrom,
'date_to' => $dateTo,
'warehouse' => $this->filterText($input['return_warehouse'] ?? ''),
'status' => $status,
];
}
/**
* @param list<array<string,mixed>> $rows
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
* @param array<string,string> $statuses
* @return list<array<string,mixed>>
*/
private function filterReturnRows(array $rows, array $filters, array $statuses): array
{
$filtered = [];
foreach ($rows as $row) {
$key = (string) ($row['_action_key'] ?? '');
$status = $statuses[$key] ?? '';
if ($filters['status'] !== '') {
$matchesStatus = $filters['status'] === 'pending'
? $status === ''
: $status === $filters['status'];
if (! $matchesStatus) {
continue;
}
}
$rowDate = $this->normalizeFilterDate($row['send_time'] ?? '');
if ($filters['date_from'] !== '' && ($rowDate === '' || $rowDate < $filters['date_from'])) {
continue;
}
if ($filters['date_to'] !== '' && ($rowDate === '' || $rowDate > $filters['date_to'])) {
continue;
}
if (
$filters['warehouse'] !== ''
&& $this->filterText($row['warehouse'] ?? '') !== $filters['warehouse']
) {
continue;
}
if ($filters['keyword'] !== '') {
$haystack = implode("\n", [
$row['send_time'] ?? '',
$row['supplier'] ?? '',
$row['barcode'] ?? '',
$row['sku_name'] ?? '',
$row['supplier_barcode'] ?? '',
$row['remark'] ?? '',
$row['title'] ?? '',
$row['wms_pick_no'] ?? '',
$row['warehouse'] ?? '',
$row['express_no'] ?? '',
]);
if (stripos($haystack, $filters['keyword']) === false) {
continue;
}
}
$row['_status'] = $status;
$filtered[] = $row;
}
return $filtered;
}
/** @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters */
private function returnFilterQuery(array $filters): array
{
return array_filter([
'return_keyword' => $filters['keyword'],
'return_date_from' => $filters['date_from'],
'return_date_to' => $filters['date_to'],
'return_warehouse' => $filters['warehouse'],
'return_status' => $filters['status'],
], static fn (string $value): bool => $value !== '');
}
private function filterText(mixed $value): string
{
return is_scalar($value) ? trim((string) $value) : '';
}
private function normalizeFilterDate(mixed $value): string
{
$raw = $this->filterText($value);
if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) !== 1) {
return '';
}
$year = (int) $matches[1];
$month = (int) $matches[2];
$day = (int) $matches[3];
return checkdate($month, $day, $year)
? sprintf('%04d-%02d-%02d', $year, $month, $day)
: '';
}
private function companyId(): int
{
return (int) (company_id() ?: 1);
}
}