*/ 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+ 家时往下翻)。 $supplierQuery = trim((string) request()->query('supplier', '')); $selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : ''; $supplierNotFound = $supplierQuery !== '' && $selected === ''; $detail = null; if ($selected !== '') { $detail = $this->detail($mine, $selected, $companyId); $totalRows = count($detail['rows']); $pages = max((int) ceil($totalRows / self::COMPARISON_PER_PAGE), 1); $currentPage = min(max((int) request()->query('compare_page', 1), 1), $pages); $detail['total_rows'] = $totalRows; $detail['page'] = $currentPage; $detail['pages'] = $pages; $detail['per_page'] = self::COMPARISON_PER_PAGE; $detail['rows'] = array_slice( $detail['rows'], ($currentPage - 1) * self::COMPARISON_PER_PAGE, self::COMPARISON_PER_PAGE, ); } return [ 'view' => 'business.pages.supplier-reconciliation.finance-data', 'supplierNames' => $supplierNames, 'supplierQuery' => $supplierQuery, 'supplierNotFound' => $supplierNotFound, '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 */ public function supplierData(): array { $companyId = $this->companyId(); $portalUser = request()->user(); $supplierLocked = $portalUser && SupplierPortalAccess::isSupplierUser($portalUser); $supplierNames = []; $supplierNotFound = false; if ($supplierLocked) { $selected = $this->supplierPortalAccess->supplierFor($portalUser); abort_if(! $selected, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。'); $supplierQuery = $selected; } else { $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, 'supplierLocked' => $supplierLocked, '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 */ 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 $input * @return array */ 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 */ 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 $input * @return array{returnPanel:array,returnWarehouses:list} */ 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> $returnRows * @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters * @return array */ 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 */ 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> 该厂家的退货明细(我方数据,展示给厂家确认扣减) */ 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 $input * @return list> */ 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 $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> $rows * @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters * @param array $statuses * @return list> */ 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); } }