CaiWuXiTong/modules/EcommerceFinance/Repositories/InternalFlowReportRepository.php
DoDo 19af680c19 fix: 心选不向买家开票
心选买家开票固定为0,收入倒推和全额内部开票保持不变。
2026-11-09 15:47:28 +08:00

573 lines
21 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Modules\EcommerceFinance\Services\InternalFlowProjection;
use Modules\EcommerceFinance\Services\InternalFlowWorkbookImporter;
use RuntimeException;
/**
* 内部流转报表读取工作簿镜像,并把预计销量、开票和进项成本按当前月份重算。
*/
class InternalFlowReportRepository
{
public const TABLE = InternalFlowWorkbookImporter::TABLE;
public const SUPPLIER_RECEIPT_OVERRIDES_TABLE = 'business_internal_flow_supplier_receipt_overrides';
/** 允许排序的列,防止把用户输入拼进 SQL。 */
public const SORTABLE = [
'company_name',
'tag',
'projected_sales',
'invoiced_to_buyer',
'internal_invoiced',
'required_input_cost',
'receipt_from_supplier',
'receipt_from_suofeiya',
'receipt_from_gujia',
'internal_transfer_1',
'internal_transfer_2',
'internal_transfer_3',
'receipt_total',
'remaining_gap',
'invoicing_ratio',
];
private const RECEIPT_FIELDS = [
'receipt_from_supplier',
'receipt_from_suofeiya',
'receipt_from_gujia',
'internal_transfer_1',
'internal_transfer_2',
'internal_transfer_3',
];
private const OVERRIDE_FIELDS = [
'receipt_from_supplier',
'receipt_from_suofeiya',
];
private readonly ConnectionInterface $connection;
public function __construct(
GenericBusinessMirrorRepository $mirror,
private readonly InternalFlowProjection $projection,
) {
$this->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<string,mixed>|null $projection
* @return array{data: list<array<string,mixed>>, total: int, summary: array<string,int|float>}
*/
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<string,mixed>|null $projection
* @return array<string,int|float>
*/
public function summary(array $filters = [], ?array $projection = null): array
{
if (! $this->ready()) {
return $this->emptySummary();
}
return $this->summaryForRows($this->rows($filters, $projection));
}
/**
* @param array<string,mixed>|null $projection
* @return array<string,mixed>|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<string,mixed>|null $projection
* @return list<array<string,mixed>>
*/
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<array<string,mixed>> $rows
* @param array<string,mixed>|null $projection
* @return list<array<string,mixed>>
*/
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;
$buyerInvoiceRate = is_array($costToIncome)
? max(0.0, (float) ($costToIncome['buyer_invoice_rate'] ?? InternalFlowProjection::BUYER_INVOICE_RATE))
: InternalFlowProjection::BUYER_INVOICE_RATE;
if ($manualCost !== null) {
$projectedSales = round($manualCost / $costToIncomeRate, 6);
$row['projected_sales'] = $projectedSales;
$row['invoiced_to_buyer'] = round(
$projectedSales * $buyerInvoiceRate,
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<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
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<string> $companyNames
* @return array<string,array<string,float|null>>
*/
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<array<string,mixed>> $rows
* @return array<string,int|float>
*/
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<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
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,
];
}
}