CaiWuXiTong/modules/EcommerceFinance/View/SupplierNegativeAlertPageData.php
苏木 a1498e6134 feat: 负数预警增加最晚出账单时间 (#1123)
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-11 20:39:09 +08:00

480 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\View;
use App\Http\Controllers\Business\PayablesFinalActions;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Throwable;
/**
* 供应商负数预警:直接读取「供应商应付(终版)→ 供应商应付表」,
* 仅保留「供应商余额 - 应付余额 + 预估金额 < 0」的记录。
*/
class SupplierNegativeAlertPageData
{
private const DATASET = 'supplier-payables-report';
private const PER_PAGE = 25;
public function __construct(
private readonly SupplierPayableOverrideStore $store,
private readonly PayablesFinalRegistry $registry,
private readonly SupplierManagementMirrorRepository $supplierManagement,
private readonly SupplierPayableReportRepository $payableReport,
private readonly SupplierNegativeAlertDingTalkWorkflow $dingTalkWorkflow,
) {
}
/** @return array<string,mixed> */
public function page(): array
{
$companyId = (int) (company_id() ?: 1);
$allRows = $this->decoratedRows();
$months = $this->monthOptions($allRows);
$supplierNames = $this->supplierOptions($allRows);
$purchaserNames = $this->purchaserOptions($allRows);
$filters = $this->filters(request()->query(), $months, $purchaserNames);
$filteredRows = $this->filterRows($allRows, $filters);
$pendingRows = $this->dingTalkWorkflow->unhandledRows($filteredRows, $companyId);
$negativeRows = $filters['handling'] === 'pending' ? $pendingRows : $filteredRows;
$filteredSupplierNames = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['supplier'] ?? '')),
$negativeRows,
))));
$filteredPurchaserNames = $this->purchaserOptions($negativeRows);
$unassignedSupplierNames = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['purchaser'] ?? '')) === ''
? trim((string) ($row['supplier'] ?? ''))
: '',
$negativeRows,
))));
$pendingSupplierNames = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['supplier'] ?? '')),
$pendingRows,
))));
$pendingPurchaserNames = $this->purchaserOptions($pendingRows);
$pendingUnassignedSupplierNames = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['purchaser'] ?? '')) === ''
? trim((string) ($row['supplier'] ?? ''))
: '',
$pendingRows,
))));
$latestResults = $this->dingTalkWorkflow->latestResultsForRows($negativeRows, $companyId);
$total = count($negativeRows);
$pages = max((int) ceil($total / self::PER_PAGE), 1);
$page = min(max((int) request()->query('page', 1), 1), $pages);
$pageRows = array_slice($negativeRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE);
$items = array_map(
static function (array $row) use ($latestResults): object {
$supplier = trim((string) ($row['supplier'] ?? ''));
$period = trim((string) ($row['summary_month'] ?? ''));
$result = $latestResults[$period . "\0" . $supplier] ?? null;
$row['handling_result'] = $result ? trim((string) $result->result_text) : '';
$row['handling_result_meta'] = $result
? '采购 ' . trim((string) $result->purchaser_name)
. ' · ' . mb_substr((string) $result->submitted_at, 0, 16)
: '';
return (object) $row;
},
$pageRows,
);
return [
'view' => 'business.pages.negative-alerts.supplier-negative-alert',
'negativeAlertFields' => $this->alertFields(),
'negativeAlertRows' => new LengthAwarePaginator(
$items,
$total,
self::PER_PAGE,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'negativeAlertMonths' => $months,
'negativeAlertMonth' => $filters['month'],
'negativeAlertSupplier' => $filters['supplier'],
'negativeAlertSupplierNames' => $supplierNames,
'negativeAlertChecker' => $filters['checker'],
'negativeAlertCheckerNames' => $this->textOptions($allRows, 'checker'),
'negativeAlertSettlementMethod' => $filters['settlement_method'],
'negativeAlertSettlementMethods' => $this->textOptions($allRows, 'settlement_method'),
'negativeAlertPurchaser' => $filters['purchaser'],
'negativeAlertPurchaserNames' => $purchaserNames,
'negativeAlertHandling' => $filters['handling'],
'negativeAlertSupplierCount' => count($filteredSupplierNames),
'negativeAlertFilteredPurchaserCount' => count($filteredPurchaserNames),
'negativeAlertUnassignedSupplierCount' => count($unassignedSupplierNames),
'negativeAlertPendingSupplierCount' => count($pendingSupplierNames),
'negativeAlertPendingPurchaserCount' => count($pendingPurchaserNames),
'negativeAlertPendingUnassignedSupplierCount' => count($pendingUnassignedSupplierNames),
'negativeAlertTotal' => $total,
'negativeAlertRobot' => $this->dingTalkWorkflow->configuration(),
'negativeAlertExportUrl' => route('business.supplier-negative-alerts.export', array_filter([
'month' => $filters['month'],
'supplier' => $filters['supplier'],
'checker' => $filters['checker'],
'settlement_method' => $filters['settlement_method'],
'purchaser' => $filters['purchaser'],
'handling' => $filters['handling'],
], static fn (string $value): bool => $value !== '')),
'negativeAlertSourceUrl' => route('business.pages.show', [
'section' => 'supplier-payables-final',
'page' => 'supplier-payables-report',
]),
];
}
/** @param array<string,mixed> $filters
* @return list<array<string,mixed>>
*/
public function rowsForFilters(array $filters): array
{
$allRows = $this->decoratedRows();
$normalized = $this->filters(
$filters,
$this->monthOptions($allRows),
$this->purchaserOptions($allRows),
);
$rows = $this->filterRows($allRows, $normalized);
return $normalized['handling'] === 'pending'
? $this->dingTalkWorkflow->unhandledRows($rows, (int) (company_id() ?: 1))
: $rows;
}
/**
* 导出与页面完全一致的筛选结果,附采购回传结果。
*
* @param array<string,mixed> $input
* @return array{fields:list<array<string,mixed>>,rows:list<array<string,mixed>>,filters:array<string,string>}
*/
public function exportData(array $input): array
{
$companyId = (int) (company_id() ?: 1);
$allRows = $this->decoratedRows();
$filters = $this->filters(
$input,
$this->monthOptions($allRows),
$this->purchaserOptions($allRows),
);
$rows = $this->filterRows($allRows, $filters);
if ($filters['handling'] === 'pending') {
$rows = $this->dingTalkWorkflow->unhandledRows($rows, $companyId);
}
$latestResults = $this->dingTalkWorkflow->latestResultsForRows($rows, $companyId);
$rows = array_map(
static function (array $row) use ($latestResults): array {
$period = trim((string) ($row['summary_month'] ?? ''));
$supplier = trim((string) ($row['supplier'] ?? ''));
$result = $latestResults[$period . "\0" . $supplier] ?? null;
$row['handling_result'] = $result ? trim((string) $result->result_text) : '';
$row['handling_result_meta'] = $result
? '采购 ' . trim((string) $result->purchaser_name)
. ' · ' . mb_substr((string) $result->submitted_at, 0, 16)
: '';
return $row;
},
$rows,
);
return [
'fields' => $this->alertFields(),
'rows' => $rows,
'filters' => $filters,
];
}
/** @return list<array<string,mixed>> */
private function alertFields(): array
{
$negativeAlertFields = [];
$fields = collect($this->registry->fields(
PayablesFinalRegistry::SECTION_SUPPLIER,
self::DATASET,
))->map(
static fn (array $definition, string $name): array => ['name' => $name] + $definition,
)->values()->all();
foreach ($fields as $field) {
if (($field['name'] ?? '') === 'negative_reason') {
$negativeAlertFields[] = [
'name' => 'latest_bill_time',
'label' => '最晚出账单时间',
'type' => 'text',
];
}
$negativeAlertFields[] = $field;
if (($field['name'] ?? '') === 'negative_reason') {
$negativeAlertFields[] = ['name' => 'purchaser', 'label' => '采购', 'type' => 'text'];
$negativeAlertFields[] = ['name' => 'handling_result', 'label' => '处理结果', 'type' => 'text'];
}
}
return $negativeAlertFields;
}
/** @return list<array<string,mixed>> */
private function decoratedRows(): array
{
$rows = $this->negativeRows();
$supplierNames = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['supplier'] ?? '')),
$rows,
))));
$purchasersBySupplier = [];
if ($supplierNames !== []) {
try {
foreach ($this->supplierManagement->suppliersByNames($supplierNames) as $supplier) {
$name = trim((string) ($supplier->scmname ?? ''));
$buyer = trim((string) ($supplier->buyer ?? ''));
if ($name !== '' && $buyer !== '') {
$purchasersBySupplier[$name][$buyer] = true;
}
}
} catch (Throwable $exception) {
report($exception);
}
}
$purchasersBySupplier = array_map(
static fn (array $buyers): string => implode('、', array_keys($buyers)),
$purchasersBySupplier,
);
$latestBillTimes = $this->latestBillTimes($rows);
foreach ($rows as &$row) {
$supplier = trim((string) ($row['supplier'] ?? ''));
$row['purchaser'] = $purchasersBySupplier[$supplier] ?? '';
$row['latest_bill_time'] = $latestBillTimes[$this->billTimeKey($row)] ?? '';
}
unset($row);
return $rows;
}
/**
* @param list<array<string,mixed>> $rows
* @return array<string,string>
*/
private function latestBillTimes(array $rows): array
{
$months = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['summary_month'] ?? '')),
$rows,
))));
if ($months === []) {
return [];
}
try {
$times = $this->payableReport->latestModifiedTimes($months);
} catch (Throwable $exception) {
report($exception);
return [];
}
$indexed = [];
foreach ($times as $time) {
$indexed[$this->billTimeKey($time)] = (string) ($time['modified_at'] ?? '');
}
return $indexed;
}
/** @param array<string,mixed> $row */
private function billTimeKey(array $row): string
{
return implode("\0", [
trim((string) ($row['summary_month'] ?? $row['month'] ?? '')),
trim((string) ($row['supplier'] ?? '')),
trim((string) ($row['checker'] ?? '')),
trim((string) ($row['settlement_method'] ?? '')),
]);
}
/** @return list<array<string,mixed>> */
private function negativeRows(): array
{
$rows = $this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:' . self::DATASET,
(int) (company_id() ?: 1),
);
$negativeRows = [];
foreach ($rows as $row) {
$balance = $this->number($row['supplier_balance'] ?? null) ?? 0.0;
$shouldPay = $this->number($row['should_pay'] ?? null) ?? 0.0;
$estimatedPrice = $this->number($row['estimated_price'] ?? null) ?? 0.0;
$alertAmount = $balance - $shouldPay + $estimatedPrice;
if ($alertAmount >= 0) {
continue;
}
$row['supplier_balance'] = $balance;
$row['_negative_alert_amount'] = round($alertAmount, 2);
$negativeRows[] = $row;
}
usort($negativeRows, static function (array $left, array $right): int {
return strcmp(
(string) ($right['summary_month'] ?? ''),
(string) ($left['summary_month'] ?? ''),
) ?: strnatcasecmp(
(string) ($left['supplier'] ?? ''),
(string) ($right['supplier'] ?? ''),
);
});
return $negativeRows;
}
/** @param list<array<string,mixed>> $rows
* @return list<string>
*/
private function monthOptions(array $rows): array
{
$months = [];
foreach ($rows as $row) {
$month = trim((string) ($row['summary_month'] ?? ''));
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
$months[$month] = true;
}
}
$months = array_keys($months);
rsort($months);
return $months;
}
/** @param list<array<string,mixed>> $rows
* @return list<string>
*/
private function supplierOptions(array $rows): array
{
$names = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['supplier'] ?? '')),
$rows,
))));
sort($names, SORT_NATURAL | SORT_FLAG_CASE);
return $names;
}
/** @param list<array<string,mixed>> $rows
* @return list<string>
*/
private function purchaserOptions(array $rows): array
{
$names = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['purchaser'] ?? '')),
$rows,
))));
sort($names, SORT_NATURAL | SORT_FLAG_CASE);
return $names;
}
/** @param list<array<string,mixed>> $rows
* @return list<string>
*/
private function textOptions(array $rows, string $field): array
{
$values = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row[$field] ?? '')),
$rows,
))));
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
return $values;
}
/** @param array<string,mixed> $input
* @param list<string> $months
* @param list<string> $purchasers
* @return array{month:string,supplier:string,checker:string,settlement_method:string,purchaser:string,handling:string}
*/
private function filters(array $input, array $months, array $purchasers): array
{
$text = static fn (string $key): string => mb_substr(
trim(is_scalar($input[$key] ?? '') ? (string) ($input[$key] ?? '') : ''),
0,
191,
);
$month = $text('month');
$purchaser = $text('purchaser');
$handling = $text('handling');
return [
'month' => in_array($month, $months, true) ? $month : '',
'supplier' => $text('supplier'),
'checker' => $text('checker'),
'settlement_method' => $text('settlement_method'),
'purchaser' => in_array($purchaser, $purchasers, true) ? $purchaser : '',
'handling' => $handling === 'pending' ? 'pending' : '',
];
}
/** @param list<array<string,mixed>> $rows
* @param array{month:string,supplier:string,checker:string,settlement_method:string,purchaser:string,handling:string} $filters
* @return list<array<string,mixed>>
*/
private function filterRows(array $rows, array $filters): array
{
return array_values(array_filter(
$rows,
static function (array $row) use ($filters): bool {
if ($filters['month'] !== '' && (string) ($row['summary_month'] ?? '') !== $filters['month']) {
return false;
}
if ($filters['supplier'] !== '') {
$supplierNeedle = mb_strtolower($filters['supplier']);
$supplierHaystack = mb_strtolower(implode("\n", [
(string) ($row['supplier'] ?? ''),
(string) ($row['company'] ?? ''),
]));
if (! str_contains($supplierHaystack, $supplierNeedle)) {
return false;
}
}
foreach (['checker', 'settlement_method'] as $field) {
if ($filters[$field] === '') {
continue;
}
if (! str_contains(
mb_strtolower((string) ($row[$field] ?? '')),
mb_strtolower($filters[$field]),
)) {
return false;
}
}
return $filters['purchaser'] === ''
|| trim((string) ($row['purchaser'] ?? '')) === $filters['purchaser'];
},
));
}
private function number(mixed $value): ?float
{
if ($value === null || trim((string) $value) === '') {
return null;
}
$normalized = str_replace([',', ',', ' '], '', trim((string) $value));
return is_numeric($normalized) ? (float) $normalized : null;
}
}