connection = $mirror->connection(); } public function ready(): bool { return $this->connection->getSchemaBuilder()->hasTable(self::TABLE); } public function supplierReceiptOverridesReady(): bool { return $this->connection->getSchemaBuilder()->hasTable(self::SUPPLIER_RECEIPT_OVERRIDES_TABLE); } /** * @param array{company_name?:string,tag?:string} $filters * @param array|null $projection * @return array{data: list>, total: int, summary: array} */ public function paginate( int $page, int $perPage, array $filters = [], ?string $sort = null, string $direction = 'asc', ?array $projection = null, ): array { if (! $this->ready()) { return [ 'data' => [], 'total' => 0, 'summary' => $this->emptySummary(), ]; } $rows = $this->rows($filters, $projection); $column = in_array($sort, self::SORTABLE, true) ? $sort : null; $rows = $this->sortRows($rows, $column, $direction); $perPage = min(max($perPage, 1), 100); $page = max($page, 1); return [ 'data' => array_slice($rows, ($page - 1) * $perPage, $perPage), 'total' => count($rows), 'summary' => $this->summaryForRows($rows), ]; } /** * @param array{company_name?:string,tag?:string} $filters * @param array|null $projection * @return array */ public function summary(array $filters = [], ?array $projection = null): array { if (! $this->ready()) { return $this->emptySummary(); } return $this->summaryForRows($this->rows($filters, $projection)); } /** * @param array|null $projection * @return array|null */ public function find(int $id, ?array $projection = null): ?array { if (! $this->ready()) { return null; } $row = $this->connection->table(self::TABLE)->where('id', $id)->first(); if ($row === null) { return null; } return $this->decorateRows([(array) $row], $projection)[0] ?? null; } public function updateSupplierReceipt(int $id, ?float $amount, ?int $userId): ?string { return $this->updateReceiptOverride($id, 'receipt_from_supplier', $amount, $userId); } public function updateSuofeiyaReceipt(int $id, ?float $amount, ?int $userId): ?string { return $this->updateReceiptOverride($id, 'receipt_from_suofeiya', $amount, $userId); } public function companyNameForRecord(int $id): ?string { if (! $this->ready()) { return null; } $companyName = $this->connection->table(self::TABLE)->where('id', $id)->value('company_name'); return $companyName === null ? null : trim((string) $companyName); } public function updateReceiptOverride(int $id, string $field, ?float $amount, ?int $userId): ?string { if (! $this->ready() || ! $this->supplierReceiptOverridesReady()) { throw new RuntimeException('内部流转供应商收票保存表尚未就绪。'); } if (! in_array($field, self::OVERRIDE_FIELDS, true)) { throw new RuntimeException('不支持的人工收票字段。'); } if ($field === 'receipt_from_suofeiya' && ! $this->connection->getSchemaBuilder()->hasColumn( self::SUPPLIER_RECEIPT_OVERRIDES_TABLE, 'receipt_from_suofeiya', )) { throw new RuntimeException('索菲亚收票保存字段尚未就绪,请先完成数据库迁移。'); } return $this->connection->transaction(function () use ($id, $field, $amount, $userId): ?string { $record = $this->connection->table(self::TABLE) ->where('id', $id) ->lockForUpdate() ->first(); if ($record === null) { return null; } $companyName = trim((string) ($record->company_name ?? '')); $now = now(); $values = [ $field => $amount === null ? null : number_format($amount, 6, '.', ''), 'updated_by' => $userId, 'updated_at' => $now, ]; $existing = $this->connection->table(self::SUPPLIER_RECEIPT_OVERRIDES_TABLE) ->where('company_name', $companyName) ->lockForUpdate() ->first(); if ($existing === null) { $this->connection->table(self::SUPPLIER_RECEIPT_OVERRIDES_TABLE)->insert([ 'company_name' => $companyName, ...$values, 'created_at' => $now, ]); } else { $this->connection->table(self::SUPPLIER_RECEIPT_OVERRIDES_TABLE) ->where('id', $existing->id) ->update($values); } return $companyName; }); } /** * @param array{company_name?:string,tag?:string} $filters * @param array|null $projection * @return list> */ private function rows(array $filters, ?array $projection): array { $query = $this->connection->table(self::TABLE); $companyName = trim((string) ($filters['company_name'] ?? '')); if ($companyName !== '') { $query->where('company_name', 'like', '%' . addcslashes($companyName, '%_\\') . '%'); } $tag = trim((string) ($filters['tag'] ?? '')); if ($tag !== '') { $query->where('tag', '=', $tag); } $rows = $query ->orderBy('sort_order') ->orderBy('id') ->get() ->map(static fn (object $row): array => (array) $row) ->all(); return $this->decorateRows($rows, $projection); } /** * @param list> $rows * @param array|null $projection * @return list> */ private function decorateRows(array $rows, ?array $projection): array { $overrides = $this->receiptOverrides(array_column($rows, 'company_name')); $salesByCompany = is_array($projection['sales_by_company'] ?? null) ? $projection['sales_by_company'] : null; $costToIncomeRules = (array) config('internal_flow_report.cost_to_income', []); $rows = array_map(function (array $row) use ( $overrides, $projection, $salesByCompany, $costToIncomeRules, ): array { $companyName = trim((string) ($row['company_name'] ?? '')); $companyOverrides = $overrides[$companyName] ?? []; foreach (self::OVERRIDE_FIELDS as $field) { if (array_key_exists($field, $companyOverrides)) { $row[$field] = $companyOverrides[$field]; } } foreach (self::RECEIPT_FIELDS as $field) { $row[$field] = $this->nullableAmount($row[$field] ?? null); } $costToIncome = $costToIncomeRules[$companyName] ?? null; $manualReceiptField = is_array($costToIncome) ? (string) ($costToIncome['receipt_field'] ?? '') : ''; $manualCost = null; if ($manualReceiptField !== '' && array_key_exists($manualReceiptField, $companyOverrides) && $companyOverrides[$manualReceiptField] !== null) { $manualCost = $this->nullableAmount($companyOverrides[$manualReceiptField]); } $costToIncomeRate = is_array($costToIncome) ? max(0.000001, (float) ($costToIncome['cost_to_income_rate'] ?? 0.9)) : 0.9; $internalInvoiceRate = is_array($costToIncome) ? max(0.0, (float) ($costToIncome['internal_invoice_rate'] ?? InternalFlowProjection::INTERNAL_INVOICE_RATE)) : InternalFlowProjection::INTERNAL_INVOICE_RATE; if ($manualCost !== null) { $projectedSales = round($manualCost / $costToIncomeRate, 6); $row['projected_sales'] = $projectedSales; $row['invoiced_to_buyer'] = round( $projectedSales * InternalFlowProjection::BUYER_INVOICE_RATE, 6, ); $row['internal_invoiced'] = round( $projectedSales * $internalInvoiceRate, 6, ); $row['required_input_cost'] = round($manualCost, 6); } elseif ($projection !== null && $salesByCompany !== null) { $projectedSales = array_key_exists($companyName, $salesByCompany) ? $this->nullableAmount($salesByCompany[$companyName]) : null; $row['projected_sales'] = $projectedSales; $row['invoiced_to_buyer'] = $projectedSales === null ? null : round($projectedSales * InternalFlowProjection::BUYER_INVOICE_RATE, 6); $row['internal_invoiced'] = $projectedSales === null ? null : round($projectedSales * InternalFlowProjection::INTERNAL_INVOICE_RATE, 6); $row['required_input_cost'] = $projectedSales === null ? null : round($projectedSales * InternalFlowProjection::INPUT_COST_RATE, 6); } else { $projectedSales = $this->nullableAmount($row['projected_sales'] ?? null); $row['projected_sales'] = $projectedSales; foreach (['invoiced_to_buyer', 'internal_invoiced', 'required_input_cost'] as $field) { $row[$field] = $this->nullableAmount($row[$field] ?? null); } } return $row; }, $rows); if ($projection !== null && $salesByCompany !== null) { $rows = $this->allocateInternalTransfers($rows); } foreach ($rows as &$row) { $receiptTotal = round(array_sum(array_map( static fn (string $field): float => (float) ($row[$field] ?? 0), self::RECEIPT_FIELDS, )), 6); $requiredInputCost = $this->nullableAmount($row['required_input_cost'] ?? null); $row['receipt_total'] = $receiptTotal; $row['remaining_gap'] = $requiredInputCost === null ? null : round($requiredInputCost - $receiptTotal, 6); } unset($row); return $rows; } /** * 上一标签的内部开票额度只分配给下一标签;下一标签公司按剩余进项需求占比分配。 * * @param list> $rows * @return list> */ private function allocateInternalTransfers(array $rows): array { $capacities = [1 => 0.0, 2 => 0.0, 3 => 0.0]; foreach ($rows as &$row) { foreach (['internal_transfer_1', 'internal_transfer_2', 'internal_transfer_3'] as $field) { $row[$field] = 0.0; } $tag = $this->tag($row['tag'] ?? null); if ($tag !== null && $tag <= 3) { $capacities[$tag] += max(0.0, (float) ($row['internal_invoiced'] ?? 0)); } } unset($row); for ($targetTag = 2; $targetTag <= 4; $targetTag++) { $sourceField = 'internal_transfer_' . ($targetTag - 1); $capacity = round($capacities[$targetTag - 1] ?? 0.0, 6); $demands = []; $totalDemand = 0.0; foreach ($rows as $index => $row) { if ($this->tag($row['tag'] ?? null) !== $targetTag) { continue; } $requiredCost = max(0.0, (float) ($row['required_input_cost'] ?? 0)); $otherReceipts = array_sum([ max(0.0, (float) ($row['receipt_from_supplier'] ?? 0)), max(0.0, (float) ($row['receipt_from_suofeiya'] ?? 0)), max(0.0, (float) ($row['receipt_from_gujia'] ?? 0)), ]); $demand = round(max(0.0, $requiredCost - $otherReceipts), 6); if ($demand <= 0) { continue; } $demands[] = ['index' => $index, 'demand' => $demand]; $totalDemand += $demand; } $remainingCapacity = min($capacity, $totalDemand); $remainingDemand = $totalDemand; $lastDemandIndex = count($demands) - 1; foreach ($demands as $position => $entry) { $demand = $entry['demand']; $allocation = $position === $lastDemandIndex ? min($remainingCapacity, $demand) : ($remainingDemand > 0 ? min($demand, $remainingCapacity, round( $remainingCapacity * $demand / $remainingDemand, 6, )) : 0.0); $rows[$entry['index']][$sourceField] = round(max(0.0, $allocation), 6); $remainingCapacity = max(0.0, round($remainingCapacity - $allocation, 6)); $remainingDemand = max(0.0, round($remainingDemand - $demand, 6)); } } return $rows; } private function tag(mixed $value): ?int { $tag = filter_var($value, FILTER_VALIDATE_INT); return $tag === false || $tag < 1 ? null : (int) $tag; } /** * @param list $companyNames * @return array> */ private function receiptOverrides(array $companyNames): array { if (! $this->supplierReceiptOverridesReady()) { return []; } $companyNames = array_values(array_unique(array_filter(array_map( static fn (mixed $name): string => trim((string) $name), $companyNames, )))); $columns = ['company_name', 'receipt_from_supplier']; if ($this->connection->getSchemaBuilder()->hasColumn( self::SUPPLIER_RECEIPT_OVERRIDES_TABLE, 'receipt_from_suofeiya', )) { $columns[] = 'receipt_from_suofeiya'; } $overrides = []; foreach (array_chunk($companyNames, 500) as $chunk) { $rows = $this->connection->table(self::SUPPLIER_RECEIPT_OVERRIDES_TABLE) ->whereIn('company_name', $chunk) ->get($columns); foreach ($rows as $row) { $values = [ 'receipt_from_supplier' => $this->nullableAmount($row->receipt_from_supplier), ]; if (in_array('receipt_from_suofeiya', $columns, true)) { $values['receipt_from_suofeiya'] = $this->nullableAmount($row->receipt_from_suofeiya); } $overrides[(string) $row->company_name] = $values; } } return $overrides; } /** * @param list> $rows * @return array */ private function summaryForRows(array $rows): array { $projectedSales = 0.0; $receiptTotal = 0.0; $remainingGap = 0.0; $ratios = []; foreach ($rows as $row) { $projectedSales += (float) ($row['projected_sales'] ?? 0); $receiptTotal += (float) ($row['receipt_total'] ?? 0); $remainingGap += (float) ($row['remaining_gap'] ?? 0); $ratio = $this->ratio($row['invoicing_ratio'] ?? null); if ($ratio !== null) { $ratios[] = $ratio; } } return [ 'companies' => count($rows), 'projected_sales' => round($projectedSales, 2), 'receipt_total' => round($receiptTotal, 2), 'remaining_gap' => round($remainingGap, 2), 'average_invoicing_ratio' => round( $ratios === [] ? 0 : array_sum($ratios) / count($ratios) * 100, 2, ), ]; } /** * @param list> $rows * @return list> */ private function sortRows(array $rows, ?string $column, string $direction): array { $directionMultiplier = strtolower($direction) === 'desc' ? -1 : 1; usort($rows, static function (array $left, array $right) use ($column, $directionMultiplier): int { if ($column !== null) { $leftValue = $left[$column] ?? null; $rightValue = $right[$column] ?? null; if ($leftValue === null || $leftValue === '') { return $rightValue === null || $rightValue === '' ? 0 : 1; } if ($rightValue === null || $rightValue === '') { return -1; } $comparison = is_numeric($leftValue) && is_numeric($rightValue) ? ((float) $leftValue <=> (float) $rightValue) : strnatcasecmp((string) $leftValue, (string) $rightValue); if ($comparison !== 0) { return $comparison * $directionMultiplier; } } $sortOrder = ((int) ($left['sort_order'] ?? 0) <=> (int) ($right['sort_order'] ?? 0)); return $sortOrder !== 0 ? $sortOrder : ((int) ($left['id'] ?? 0) <=> (int) ($right['id'] ?? 0)); }); return $rows; } private function nullableAmount(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (! is_numeric($value)) { return null; } return round((float) $value, 6); } private function ratio(mixed $value): ?float { $text = str_replace([',', ' ', '%'], '', trim((string) $value)); if ($text === '' || $text === '/' || ! is_numeric($text)) { return null; } $ratio = (float) $text; if (str_contains((string) $value, '%') || $ratio > 1) { $ratio /= 100; } return $ratio >= 0 ? $ratio : null; } /** @return array{companies:int,projected_sales:float,receipt_total:float,remaining_gap:float,average_invoicing_ratio:float} */ private function emptySummary(): array { return [ 'companies' => 0, 'projected_sales' => 0.0, 'receipt_total' => 0.0, 'remaining_gap' => 0.0, 'average_invoicing_ratio' => 0.0, ]; } }