CaiWuXiTong/modules/EcommerceFinance/Repositories/InternalFlowReportRepository.php
DoDo 0174a056ac feat: 联动内部流转预计销量并支持供应商收票
预计销量自动读取主体维度经营报表上月业绩;开票与进项成本按 20%/25%/80% 计算;供应商收票支持人工覆盖并跨导入保留。
2026-11-09 12:38:19 +08:00

399 lines
14 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 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
{
if (! $this->ready() || ! $this->supplierReceiptOverridesReady()) {
throw new RuntimeException('内部流转供应商收票保存表尚未就绪。');
}
return $this->connection->transaction(function () use ($id, $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 = [
'receipt_from_supplier' => $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;
return array_map(function (array $row) use ($overrides, $projection, $salesByCompany): array {
$companyName = trim((string) ($row['company_name'] ?? ''));
if (array_key_exists($companyName, $overrides)) {
$row['receipt_from_supplier'] = $overrides[$companyName];
}
foreach (self::RECEIPT_FIELDS as $field) {
$row[$field] = $this->nullableAmount($row[$field] ?? null);
}
$receiptTotal = round(array_sum(array_map(
static fn (string $field): float => (float) ($row[$field] ?? 0),
self::RECEIPT_FIELDS,
)), 6);
$row['receipt_total'] = $receiptTotal;
if ($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);
}
}
$requiredInputCost = $this->nullableAmount($row['required_input_cost'] ?? null);
$row['remaining_gap'] = $requiredInputCost === null
? null
: round($requiredInputCost - $receiptTotal, 6);
return $row;
}, $rows);
}
/**
* @param list<string> $companyNames
* @return 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,
))));
$overrides = [];
foreach (array_chunk($companyNames, 500) as $chunk) {
$rows = $this->connection->table(self::SUPPLIER_RECEIPT_OVERRIDES_TABLE)
->whereIn('company_name', $chunk)
->get(['company_name', 'receipt_from_supplier']);
foreach ($rows as $row) {
$overrides[(string) $row->company_name] = $this->nullableAmount($row->receipt_from_supplier);
}
}
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,
];
}
}