262 lines
11 KiB
PHP
262 lines
11 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* 汇总商品月加权平均单价结果,并将入库明细差异按原因分类。
|
||
*/
|
||
class ProductMonthlyWeightedAverageReporter
|
||
{
|
||
// 异常阈值未由业务另行指定:取至少 100 行,且不超过总行数的 0.1%。
|
||
private const ANOMALY_THRESHOLD_MIN_ROWS = 100;
|
||
|
||
private const ANOMALY_THRESHOLD_RATE = 0.001;
|
||
|
||
private const ROW_TABLE = 'product_monthly_weighted_average_rows';
|
||
|
||
private const STATE_TABLE = 'product_monthly_weighted_average_states';
|
||
|
||
private const INBOUND_TABLE = 'inventory_inbound_detail_rows';
|
||
|
||
private const INVENTORY_MONTH_TABLE = 'inventory_report_authoritative_months';
|
||
|
||
private const INVENTORY_ROW_TABLE = 'inventory_report_authoritative_rows';
|
||
|
||
/** @return array<string,mixed> */
|
||
public function build(string $connection, CarbonImmutable $month): array
|
||
{
|
||
$month = $month->startOfMonth();
|
||
$db = DB::connection($connection);
|
||
$baseQuery = static fn () => $db->table(self::ROW_TABLE)
|
||
->where('report_month', $month->toDateString());
|
||
|
||
$totals = $baseQuery()
|
||
->selectRaw('count(*) as total_rows')
|
||
->selectRaw('coalesce(sum(opening_quantity), 0) as opening_quantity')
|
||
->selectRaw('coalesce(sum(opening_amount), 0) as opening_amount')
|
||
->selectRaw('coalesce(sum(purchase_quantity), 0) as purchase_quantity')
|
||
->selectRaw('coalesce(sum(purchase_amount), 0) as purchase_amount')
|
||
->selectRaw('sum(case when weighted_average_unit_price is not null then 1 else 0 end) as weighted_price_rows')
|
||
->selectRaw('sum(case when weighted_average_unit_price is null then 1 else 0 end) as missing_weighted_rows')
|
||
->selectRaw('sum(case when pricing_status = ? then 1 else 0 end) as manual_review_rows', ['待人工核查'])
|
||
->first();
|
||
|
||
if ($totals === null || (int) $totals->total_rows === 0) {
|
||
return [
|
||
'status' => 'missing',
|
||
'report_month' => $month->toDateString(),
|
||
'message' => '该月份尚未生成商品月加权平均单价。',
|
||
'generated_at' => now()->toDateTimeString(),
|
||
];
|
||
}
|
||
|
||
$weightedKeys = [];
|
||
foreach ($baseQuery()->select('product_code', 'warehouse_short_name')->cursor() as $row) {
|
||
$weightedKeys[$this->key($row->product_code, $row->warehouse_short_name)] = true;
|
||
}
|
||
|
||
$inbound = [
|
||
'source_rows' => 0,
|
||
'source_quantity' => 0.0,
|
||
'source_amount' => 0.0,
|
||
'matched_rows' => 0,
|
||
'matched_quantity' => 0.0,
|
||
'matched_amount' => 0.0,
|
||
'unmatched_rows' => 0,
|
||
'unmatched_quantity' => 0.0,
|
||
'unmatched_amount' => 0.0,
|
||
'unmatched_groups' => [],
|
||
];
|
||
foreach ($db->table(self::INBOUND_TABLE)
|
||
->where('report_month', $month->toDateString())
|
||
->selectRaw('upper(barcode) as code, warehouse_short_name, count(*) as rows_count')
|
||
->selectRaw('coalesce(sum(quantity), 0) as quantity')
|
||
->selectRaw('coalesce(sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)), 0) as amount')
|
||
->groupByRaw('upper(barcode), warehouse_short_name')
|
||
->cursor() as $row) {
|
||
$rows = (int) $row->rows_count;
|
||
$quantity = (float) $row->quantity;
|
||
$amount = (float) $row->amount;
|
||
$inbound['source_rows'] += $rows;
|
||
$inbound['source_quantity'] += $quantity;
|
||
$inbound['source_amount'] += $amount;
|
||
|
||
if (isset($weightedKeys[$this->key($row->code, $row->warehouse_short_name)])) {
|
||
$inbound['matched_rows'] += $rows;
|
||
$inbound['matched_quantity'] += $quantity;
|
||
$inbound['matched_amount'] += $amount;
|
||
continue;
|
||
}
|
||
|
||
$inbound['unmatched_rows'] += $rows;
|
||
$inbound['unmatched_quantity'] += $quantity;
|
||
$inbound['unmatched_amount'] += $amount;
|
||
$inbound['unmatched_groups'][] = [
|
||
'product_code' => trim((string) $row->code),
|
||
'warehouse_short_name' => trim((string) $row->warehouse_short_name),
|
||
'source_rows' => $rows,
|
||
'quantity' => $this->number($quantity),
|
||
'amount' => $this->number($amount),
|
||
'reason' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||
];
|
||
}
|
||
usort(
|
||
$inbound['unmatched_groups'],
|
||
static fn (array $left, array $right): int =>
|
||
abs((float) $right['amount']) <=> abs((float) $left['amount']),
|
||
);
|
||
$inbound = $this->roundNestedNumbers($inbound);
|
||
|
||
$monthState = $db->table(self::INVENTORY_MONTH_TABLE)
|
||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||
->where('source_month', $month->toDateString())
|
||
->first();
|
||
$generation = $monthState?->active_generation;
|
||
$actualSourceRows = $generation === null
|
||
? 0
|
||
: $db->table(self::INVENTORY_ROW_TABLE)
|
||
->where('generation', (string) $generation)
|
||
->count();
|
||
$expectedSourceRows = (int) ($monthState?->source_row_count ?? 0);
|
||
|
||
$weightedAnomalyRows = $baseQuery()
|
||
->where(function ($query): void {
|
||
$query->whereNull('weighted_average_unit_price')
|
||
->orWhere('pricing_status', '待人工核查');
|
||
})
|
||
->count();
|
||
$reasons = [];
|
||
if ((int) $totals->missing_weighted_rows > 0) {
|
||
$reasons[] = [
|
||
'code' => 'missing_weighted_price',
|
||
'label' => '加权平均单价为空',
|
||
'rows' => (int) $totals->missing_weighted_rows,
|
||
];
|
||
}
|
||
if ((int) $totals->manual_review_rows > 0) {
|
||
$reasons[] = [
|
||
'code' => 'manual_review',
|
||
'label' => '待人工核查',
|
||
'rows' => (int) $totals->manual_review_rows,
|
||
];
|
||
}
|
||
if ((int) $inbound['unmatched_rows'] > 0) {
|
||
$reasons[] = [
|
||
'code' => 'inbound_without_inventory_dimension',
|
||
'label' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||
'rows' => (int) $inbound['unmatched_rows'],
|
||
'groups' => count($inbound['unmatched_groups']),
|
||
];
|
||
}
|
||
if ($actualSourceRows !== $expectedSourceRows) {
|
||
$reasons[] = [
|
||
'code' => 'upstream_source_row_mismatch',
|
||
'label' => '上游进销存源表行数与已激活明细不一致',
|
||
'rows' => abs($actualSourceRows - $expectedSourceRows),
|
||
];
|
||
}
|
||
|
||
$anomalyRows = $weightedAnomalyRows + (int) $inbound['unmatched_rows'];
|
||
$threshold = max(
|
||
self::ANOMALY_THRESHOLD_MIN_ROWS,
|
||
(int) ceil((int) $totals->total_rows * self::ANOMALY_THRESHOLD_RATE),
|
||
);
|
||
|
||
return [
|
||
'status' => $reasons === [] ? 'computed' : 'review_required',
|
||
'report_month' => $month->toDateString(),
|
||
'totals' => [
|
||
'rows' => (int) $totals->total_rows,
|
||
'opening_quantity' => $this->number((float) $totals->opening_quantity),
|
||
'opening_amount' => $this->number((float) $totals->opening_amount),
|
||
'purchase_quantity' => $this->number((float) $totals->purchase_quantity),
|
||
'purchase_amount' => $this->number((float) $totals->purchase_amount),
|
||
'weighted_price_rows' => (int) $totals->weighted_price_rows,
|
||
'missing_weighted_rows' => (int) $totals->missing_weighted_rows,
|
||
'manual_review_rows' => (int) $totals->manual_review_rows,
|
||
],
|
||
'inbound' => $inbound,
|
||
'source' => [
|
||
'expected_rows' => $expectedSourceRows,
|
||
'active_rows' => $actualSourceRows,
|
||
'row_difference' => $actualSourceRows - $expectedSourceRows,
|
||
'status' => (string) ($monthState?->status ?? 'missing'),
|
||
'active_generation' => $generation,
|
||
'source_filename' => $monthState?->source_filename,
|
||
],
|
||
'anomalies' => [
|
||
'affected_rows' => $anomalyRows,
|
||
'threshold_rows' => $threshold,
|
||
'threshold_exceeded' => $anomalyRows > $threshold,
|
||
'reasons' => $reasons,
|
||
],
|
||
'state' => $db->table(self::STATE_TABLE)
|
||
->where('report_month', $month->toDateString())
|
||
->first(),
|
||
'generated_at' => now()->toDateTimeString(),
|
||
];
|
||
}
|
||
|
||
/** @param array<string,mixed> $report */
|
||
public function brief(array $report): string
|
||
{
|
||
if (($report['status'] ?? null) === 'missing') {
|
||
return (string) ($report['message'] ?? '商品月加权平均单价尚未生成');
|
||
}
|
||
|
||
$totals = $report['totals'] ?? [];
|
||
$inbound = $report['inbound'] ?? [];
|
||
$anomalies = $report['anomalies'] ?? [];
|
||
$reasonLabels = array_map(
|
||
static fn (array $reason): string =>
|
||
(string) ($reason['label'] ?? $reason['code'] ?? '未知异常'),
|
||
$anomalies['reasons'] ?? [],
|
||
);
|
||
|
||
return sprintf(
|
||
'%s:总行数 %s;期初 %s 件 / %s;入库 %s 件 / %s;加权单价 %s 行;异常 %s 行%s',
|
||
substr((string) ($report['report_month'] ?? ''), 0, 7),
|
||
number_format((int) ($totals['rows'] ?? 0)),
|
||
number_format((float) ($totals['opening_quantity'] ?? 0), 2),
|
||
number_format((float) ($totals['opening_amount'] ?? 0), 2),
|
||
number_format((float) ($inbound['matched_quantity'] ?? 0), 2),
|
||
number_format((float) ($inbound['matched_amount'] ?? 0), 2),
|
||
number_format((int) ($totals['weighted_price_rows'] ?? 0)),
|
||
number_format((int) ($anomalies['affected_rows'] ?? 0)),
|
||
$reasonLabels === [] ? '' : '(' . implode('、', $reasonLabels) . ')',
|
||
);
|
||
}
|
||
|
||
private function key(mixed $productCode, mixed $warehouseShortName): string
|
||
{
|
||
return mb_strtoupper(trim((string) $productCode))
|
||
. "\0"
|
||
. trim((string) $warehouseShortName);
|
||
}
|
||
|
||
private function number(float $value): float
|
||
{
|
||
return round($value, 6);
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $value
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function roundNestedNumbers(array $value): array
|
||
{
|
||
foreach ($value as $key => $item) {
|
||
if (is_array($item)) {
|
||
$value[$key] = $this->roundNestedNumbers($item);
|
||
} elseif (is_float($item)) {
|
||
$value[$key] = round($item, 6);
|
||
}
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
}
|