CaiWuXiTong/app/Console/Commands/ComputeProductMonthlyWeightedAverage.php

261 lines
12 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);
$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,
);
$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;
}
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): 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号(新价当月未生效)→ 取变价前价格、状态保持原状态;其余取变价后价格。
$changeTime = $change['time'] ?? null;
$changeCreated = $change['created'] ?? null;
$newPricePending = false;
if ($changePrice !== null && ($changeTime === null || $changeCreated === null)) {
$changePrice = null;
} elseif ($changePrice !== null && CarbonImmutable::parse($changeCreated)->greaterThan($deadline)) {
$newPricePending = true;
$changePrice = $change['before'] ?? $changePrice;
}
// 更改价格 + 定价状态(表2 第10条)
if ($openingUnitPrice !== null) {
if ($previousStatus !== '预估价') {
$adjusted = $openingUnitPrice;
$status = $previousStatus !== '' ? $previousStatus : '已定价';
} elseif ($changePrice !== null) {
$adjusted = $changePrice;
$status = $newPricePending ? '未定价' : '已定价';
} else {
$adjusted = $openingUnitPrice;
// 未匹配到工作平台价格 → 用原始价格,状态统一记为「未定价」(原「预估价」)。
$status = '未定价';
}
} elseif ($changePrice !== null) {
$adjusted = $changePrice;
$status = '已定价';
} elseif ($cost !== null) {
$adjusted = $cost;
$status = $workPrice !== null ? '已定价' : '未定价';
} else {
$adjusted = null;
$status = '待人工核查';
}
// 采购数量/入库金额来自入库明细(大写条码 + 仓库简称汇总),采购单价 = 入库金额 ÷ 采购数量
$purchaseQuantity = (float) ($inbound['quantity'] ?? 0);
$purchaseAmount = $inbound === null ? null : round((float) ($inbound['total'] ?? 0), 2);
$purchaseUnitPrice = ($inbound === null || $purchaseQuantity == 0.0)
? null
: round((float) ($inbound['total'] ?? 0) / $purchaseQuantity, 2);
$openingAmount = $adjusted === null ? null : round($openingQuantity * $adjusted, 2);
$denominator = $openingQuantity + $purchaseQuantity;
if ($denominator === 0.0) {
$weighted = $adjusted === null ? null : round($adjusted, 2);
} else {
$weighted = round((($openingAmount ?? 0) + ($purchaseAmount ?? 0)) / $denominator, 2);
}
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,
];
}
}