CaiWuXiTong/app/Console/Commands/ComputeProductMonthlyWeightedAverage.php

302 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
*/
class ComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
protected $description = '按取数逻辑计算商品月加权平均单价';
public function handle(): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'product_monthly_weighted_average_rows';
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
$this->info('待计算 SKU:' . count($codes));
$cost = $this->costPrices($connection, $codes);
$work = $this->workPlatformPrices($connection, $codes);
$change = $this->priceChanges($connection, $codes);
$inbound = $this->inboundAggregate($connection, $month);
$costRecordSkus = $this->costPriceRecordSkus($connection, $codes);
$total = (clone $query)->count();
$computed = 0;
$now = now();
for ($offset = 0; $offset < $total; $offset += 1000) {
$rows = (clone $query)->orderBy('row_key')->offset($offset)->limit(1000)
->get([
'row_key', 'barcode_warehouse', 'warehouse_short_name', 'product_code', 'base_source', 'imported_at',
'opening_quantity', 'opening_unit_price', 'previous_pricing_status', 'purchase_quantity',
]);
$updates = [];
foreach ($rows as $row) {
// 普通商品资料与进销存的商品编码大小写不一致,统一按大写匹配。
$code = mb_strtoupper(trim((string) $row->product_code));
$costPrice = is_array($cost) ? ($cost[$code] ?? null) : null;
$workPrice = is_array($work) ? ($work[$code] ?? null) : null;
$changePrice = is_array($change) ? ($change[$code] ?? null) : null;
$inboundKey = $code . "\0" . trim((string) $row->warehouse_short_name);
$payload = $this->calculate(
$row,
$costPrice,
$workPrice,
$changePrice,
$deadline,
$inbound[$inboundKey] ?? null,
isset($costRecordSkus[$code]),
);
$updates[] = array_merge([
'report_month' => $month->toDateString(),
'row_key' => $row->row_key,
'barcode_warehouse' => $row->barcode_warehouse,
'warehouse_short_name' => $row->warehouse_short_name,
'product_code' => $row->product_code,
'base_source' => $row->base_source,
'imported_at' => $row->imported_at,
'computed_at' => $now,
], $payload);
$computed++;
}
if ($updates !== []) {
DB::connection($connection)->table($table)->upsert(
$updates,
['report_month', 'row_key'],
['adjusted_unit_price', 'opening_amount', 'purchase_quantity', 'purchase_unit_price', 'purchase_amount', 'weighted_average_unit_price', 'pricing_status', 'computed_at'],
);
}
}
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
['report_month' => $month->toDateString()],
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
);
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
return self::SUCCESS;
}
/**
* @return array<string,float>
*/
private function costPrices(string $connection, array $codes): array
{
$map = [];
foreach (array_chunk($codes, 2000) as $chunk) {
foreach (DB::connection($connection)->table('ops_jst_sku')
->whereIn('sku_id', $chunk)->whereNotNull('cost_price')->get(['sku_id', 'cost_price']) as $row) {
$map[mb_strtoupper(trim((string) $row->sku_id))] = (float) $row->cost_price;
}
}
return $map;
}
/**
* @return array<string,array{price:float,effect:?string}>
*/
private function workPlatformPrices(string $connection, array $codes): array
{
$map = [];
$now = now();
foreach (array_chunk($codes, 2000) as $chunk) {
$rows = DB::connection($connection)->table('js_pm_price_adjustment')
->whereIn('skuCode', $chunk)
->whereNotNull('purchasePrice')
->where(function ($query) use ($now): void {
$query->whereNull('effectTime')->orWhere('effectTime', '<=', $now);
})
->orderBy('effectTime')
->get(['skuCode', 'purchasePrice', 'effectTime']);
foreach ($rows as $row) {
$map[mb_strtoupper(trim((string) $row->skuCode))] = [
'price' => (float) $row->purchasePrice,
'effect' => $row->effectTime === null ? null : (string) $row->effectTime,
];
}
}
return $map;
}
/**
* @return array<string,array{price:float,time:?string}>
*/
/**
* 入库明细汇总:按「大写条码 + 仓库简称」汇总库存变动量与总计(金额 + 预估金额)。
*
* @return array<string,array{quantity:float,total:float}>
*/
private function inboundAggregate(string $connection, CarbonImmutable $month): array
{
$map = [];
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('inventory_inbound_detail_rows')) {
return $map;
}
foreach (DB::connection($connection)->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->selectRaw('warehouse_short_name, upper(barcode) as code, sum(quantity) as quantity, sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)) as total')
->groupBy('warehouse_short_name')
->groupByRaw('upper(barcode)')
->get() as $row) {
$key = mb_strtoupper(trim((string) $row->code)) . "\0" . trim((string) $row->warehouse_short_name);
$map[$key] = ['quantity' => (float) $row->quantity, 'total' => (float) $row->total];
}
return $map;
}
/**
* 有"聚水潭成本价记录"(operated_at 非空)的商品编码集合。
*
* @return array<string,bool>
*/
private function costPriceRecordSkus(string $connection, array $codes): array
{
$map = [];
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('jushuitan_cost_price_history_rows')) {
return $map;
}
foreach (array_chunk($codes, 2000) as $chunk) {
$rows = DB::connection($connection)->table('jushuitan_cost_price_history_rows')
->whereNotNull('operated_at')
->where(function ($query) use ($chunk): void {
$query->whereIn('sku_id', $chunk)->orWhereIn('sku_id_normalized', $chunk);
})
->get(['sku_id', 'sku_id_normalized']);
foreach ($rows as $row) {
foreach ([$row->sku_id, $row->sku_id_normalized] as $value) {
$key = mb_strtoupper(trim((string) $value));
if ($key !== '') {
$map[$key] = true;
}
}
}
}
return $map;
}
private function priceChanges(string $connection, array $codes): array
{
$map = [];
foreach (array_chunk($codes, 2000) as $chunk) {
$rows = DB::connection($connection)->table('js_pm_price_adjustment_log')
->whereIn('skucode', $chunk)
->whereNotNull('afterPrice')
->orderBy('effectStartTime')
->get(['skucode', 'afterPrice', 'originalPrice', 'effectStartTime', 'createTime']);
foreach ($rows as $row) {
$map[mb_strtoupper(trim((string) $row->skucode))] = [
'price' => (float) $row->afterPrice,
'before' => $row->originalPrice === null ? null : (float) $row->originalPrice,
'time' => $row->effectStartTime === null ? null : (string) $row->effectStartTime,
'created' => $row->createTime === null ? null : (string) $row->createTime,
];
}
}
return $map;
}
/**
* @param array{price:float,effect:?string}|null $work
* @param array{price:float,time:?string}|null $change
* @return array<string,mixed>
*/
private function calculate(object $row, ?float $cost, ?array $work, ?array $change, CarbonImmutable $deadline, ?array $inbound = null, bool $hasCostRecord = false): array
{
$openingQuantity = $row->opening_quantity === null ? 0.0 : (float) $row->opening_quantity;
$purchaseQuantity = $row->purchase_quantity === null ? 0.0 : (float) $row->purchase_quantity;
$openingUnitPrice = $row->opening_unit_price === null ? null : (float) $row->opening_unit_price;
$previousStatus = trim((string) ($row->previous_pricing_status ?? ''));
$changePrice = $change['price'] ?? null;
$workPrice = $work['price'] ?? null;
// 最终规则:生效开始日期为空 → 未匹配到工作平台价格;
// 生效开始日期 > 本月,或记录时间 > 次月2号(记录时间为空视为 ≤ 次月2号)→ 新价当月未生效,取变价前价格。
$changeTime = $change['time'] ?? null;
$changeCreated = $change['created'] ?? null;
$monthStart = $deadline->startOfMonth();
$hasWorkPlatformRecord = $changePrice !== null && $changeTime !== null;
$newPricePending = false;
if ($hasWorkPlatformRecord) {
$newPricePending = CarbonImmutable::parse($changeTime)->greaterThanOrEqualTo($monthStart)
|| ($changeCreated !== null && CarbonImmutable::parse($changeCreated)->greaterThan($deadline));
$changePrice = $newPricePending ? ($change['before'] ?? $changePrice) : $changePrice;
}
$previousStatus = in_array($previousStatus, ['预估价', '未定价'], true) ? '未定价' : $previousStatus;
// 更改价格 + 定价状态(表2 第10条)
if ($openingUnitPrice !== null) {
if ($previousStatus !== '未定价') {
$adjusted = $openingUnitPrice;
$status = $previousStatus !== '' ? $previousStatus : '已定价';
} elseif ($hasWorkPlatformRecord) {
$adjusted = $changePrice;
$status = $newPricePending ? '未定价' : '已定价';
} else {
$adjusted = $openingUnitPrice;
$status = '未定价';
}
} elseif ($hasWorkPlatformRecord) {
$adjusted = $changePrice;
$status = $newPricePending ? '未定价' : '已定价';
} elseif ($cost !== null) {
// 方案②:只有"聚水潭成本价记录"里有生效记录(operated_at 非空)才算有生效日期 → 已定价。
$adjusted = $cost;
$status = $hasCostRecord ? '已定价' : '未定价';
} else {
$adjusted = null;
$status = '待人工核查';
}
// 采购数量/入库金额来自入库明细(大写条码 + 仓库简称汇总),采购单价 = 入库金额 ÷ 采购数量
$purchaseQuantity = (float) ($inbound['quantity'] ?? 0);
// 加权平均单价按“先除再舍入”计算,因此这里保留未舍入的金额参与除法。
$purchaseAmountRaw = $inbound === null ? 0.0 : (float) ($inbound['total'] ?? 0);
$purchaseAmount = $inbound === null ? null : round($purchaseAmountRaw, 2);
$purchaseUnitPrice = ($inbound === null || $purchaseQuantity == 0.0)
? null
: round((float) ($inbound['total'] ?? 0) / $purchaseQuantity, 2);
$openingAmountRaw = $adjusted === null ? 0.0 : $openingQuantity * $adjusted;
$openingAmount = $adjusted === null ? null : round($openingAmountRaw, 2);
$denominator = $openingQuantity + $purchaseQuantity;
if ($denominator === 0.0) {
// 四项全 0:加权平均单价取"更改价格"(按两位舍入后的值)。
$weighted = $adjusted === null ? null : round($adjusted, 2);
} else {
// 先除再舍入:用未舍入的期初金额与入库金额相除,最后统一保留两位。
$weighted = round(($openingAmountRaw + $purchaseAmountRaw) / $denominator, 4);
}
return [
'purchase_quantity' => $purchaseQuantity,
'adjusted_unit_price' => $adjusted === null ? null : round($adjusted, 2),
'opening_amount' => $openingAmount,
'purchase_unit_price' => $purchaseUnitPrice === null ? null : round($purchaseUnitPrice, 2),
'purchase_amount' => $purchaseAmount,
'weighted_average_unit_price' => $weighted,
'pricing_status' => $status,
];
}
}