573 lines
21 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|