334 lines
16 KiB
PHP
334 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
|
||
/**
|
||
* 商品月加权平均单价:导入某个月的期初基数。
|
||
*
|
||
* 基数来源两类:
|
||
* 1) 首个可用月份(7月)来自财务提供的《商品月末加权平均单价》表1,包含仓库简称、商品编码和上月加权平均单价;
|
||
* 2) 其余月份由上月算出的加权平均单价滚动生成(--from-previous)。
|
||
* 无论哪一类,都会并入进销存总表里"代发仓"的商品(代发仓没有库存,采购数量取销售出库)。
|
||
*/
|
||
class ImportProductMonthlyWeightedAverageBase extends Command
|
||
{
|
||
protected $signature = 'product:monthly-weighted-average:base
|
||
{month : 报表月份,例如 2026-07}
|
||
{--file= : 期初基数 Excel(表1),首月使用}
|
||
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
|
||
|
||
protected $description = '导入/生成商品月加权平均单价的期初基数';
|
||
|
||
public function handle(): int
|
||
{
|
||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||
$rowsTable = 'product_monthly_weighted_average_rows';
|
||
$now = now();
|
||
|
||
$inventory = $this->inventoryAggregate($connection, $month);
|
||
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
|
||
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
|
||
$this->info('进销存(排除不看)合计 ' . count($inventory) . ' 个 仓库简称+商品编码');
|
||
|
||
DB::connection($connection)->table($rowsTable)->where('report_month', $month->toDateString())->delete();
|
||
|
||
$payload = [];
|
||
$baseRows = 0;
|
||
$deliveryRows = 0;
|
||
$mirrorRows = 0;
|
||
|
||
$file = (string) ($this->option('file') ?? '');
|
||
if ($file !== '') {
|
||
foreach ($this->readBaseFile($file) as $row) {
|
||
$payload[$this->rowKey($month, $row['warehouse_short_name'], $row['product_code'])] = [
|
||
'report_month' => $month->toDateString(),
|
||
'row_key' => $this->rowKey($month, $row['warehouse_short_name'], $row['product_code']),
|
||
'barcode_warehouse' => $row['barcode_warehouse'],
|
||
'warehouse_short_name' => $row['warehouse_short_name'],
|
||
'product_code' => $row['product_code'],
|
||
'opening_unit_price' => $row['opening_unit_price'],
|
||
'previous_pricing_status' => $row['previous_pricing_status'],
|
||
'base_source' => 'base',
|
||
'imported_at' => $now,
|
||
];
|
||
$baseRows++;
|
||
}
|
||
} elseif ($this->option('from-previous')) {
|
||
$previous = $month->subMonthNoOverflow();
|
||
foreach (DB::connection($connection)->table($rowsTable)
|
||
->where('report_month', $previous->toDateString())
|
||
->get(['barcode_warehouse', 'warehouse_short_name', 'product_code', 'pricing_status', 'weighted_average_unit_price']) as $row) {
|
||
$key = $this->rowKey($month, $row->warehouse_short_name, $row->product_code);
|
||
$payload[$key] = [
|
||
'report_month' => $month->toDateString(),
|
||
'row_key' => $key,
|
||
'barcode_warehouse' => $row->barcode_warehouse,
|
||
'warehouse_short_name' => $row->warehouse_short_name,
|
||
'product_code' => $row->product_code,
|
||
'opening_unit_price' => $row->weighted_average_unit_price,
|
||
'previous_pricing_status' => $row->pricing_status,
|
||
'base_source' => 'base',
|
||
'imported_at' => $now,
|
||
];
|
||
$baseRows++;
|
||
}
|
||
} else {
|
||
$this->error('请给出 --file=表1.xlsx(首月)或 --from-previous(滚动月份)。');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
// 代发仓:没有库存,但必须出现在这张表里,采购数量取销售出库。
|
||
foreach ($inventory as $key => $item) {
|
||
$rowKey = $this->rowKey($month, $item['warehouse_short_name'], $item['product_code']);
|
||
if (isset($payload[$rowKey])) {
|
||
continue;
|
||
}
|
||
$isDelivery = $this->isDeliveryWarehouse($item['warehouse_short_name']);
|
||
$payload[$rowKey] = [
|
||
'report_month' => $month->toDateString(),
|
||
'row_key' => $rowKey,
|
||
// 条码+仓库 = 商品编码 + 仓库简称
|
||
'barcode_warehouse' => $item['product_code'] . $item['warehouse_short_name'],
|
||
'warehouse_short_name' => $item['warehouse_short_name'],
|
||
'product_code' => $item['product_code'],
|
||
'opening_unit_price' => null,
|
||
'previous_pricing_status' => null,
|
||
'base_source' => $isDelivery ? 'delivery' : 'mirror',
|
||
'imported_at' => $now,
|
||
];
|
||
$isDelivery ? $deliveryRows++ : $mirrorRows++;
|
||
}
|
||
|
||
// 南昌樵舍仓的采购数量取自"简尚-江西仓送货主仓",这些条码也要一并纳入行维度,
|
||
// 否则只存在于江西仓送货的单据会因为表里没有对应行而丢掉采购数量。
|
||
foreach (array_keys($jiangxiPurchase) as $code) {
|
||
$rowKey = $this->rowKey($month, '南昌樵舍仓', $code);
|
||
if (isset($payload[$rowKey])) {
|
||
continue;
|
||
}
|
||
$payload[$rowKey] = [
|
||
'report_month' => $month->toDateString(),
|
||
'row_key' => $rowKey,
|
||
'barcode_warehouse' => $code . '南昌樵舍仓',
|
||
'warehouse_short_name' => '南昌樵舍仓',
|
||
'product_code' => $code,
|
||
'opening_unit_price' => null,
|
||
'previous_pricing_status' => null,
|
||
'base_source' => 'mirror',
|
||
'imported_at' => $now,
|
||
];
|
||
$mirrorRows++;
|
||
}
|
||
|
||
$matched = 0;
|
||
foreach ($payload as $key => $row) {
|
||
$item = $inventory[$row['warehouse_short_name'] . "\0" . $row['product_code']] ?? null;
|
||
// 代发仓判定不能只看 base_source(滚动月份会丢标记),按仓库简称再兜一层。
|
||
$isDelivery = $row['base_source'] === 'delivery'
|
||
|| str_contains((string) $row['warehouse_short_name'], '代发仓');
|
||
$isNanchang = $row['warehouse_short_name'] === '南昌樵舍仓';
|
||
// 原始数量只取"主仓"的期初数量(仓库名称含主仓字样)。
|
||
$openingQuantity = $isDelivery ? 0.0 : (float) ($item['opening_quantity'] ?? 0);
|
||
if ($isNanchang) {
|
||
// 南昌樵舍仓是特殊仓库:期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
|
||
$openingQuantity = (float) ($nanchangOpening[$row['product_code']] ?? 0);
|
||
}
|
||
$purchaseQuantity = $isDelivery
|
||
? abs((float) ($item['sales_out'] ?? 0))
|
||
: ($isNanchang
|
||
// 南昌樵舍仓是特殊仓库:采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
|
||
? (float) ($jiangxiPurchase[$row['product_code']] ?? 0)
|
||
: (float) ($item['purchase_in'] ?? 0));
|
||
if ($item !== null) {
|
||
$matched++;
|
||
}
|
||
$payload[$key]['product_name'] = $item['category'] ?? null;
|
||
$payload[$key]['opening_quantity'] = $openingQuantity;
|
||
$payload[$key]['purchase_quantity'] = $purchaseQuantity;
|
||
$payload[$key]['opening_original_amount'] = $row['opening_unit_price'] === null
|
||
? null
|
||
: round($openingQuantity * (float) $row['opening_unit_price'], 2);
|
||
}
|
||
|
||
foreach (array_chunk($payload, 1000, true) as $chunk) {
|
||
DB::connection($connection)->table($rowsTable)->insert(array_values($chunk));
|
||
}
|
||
|
||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||
['report_month' => $month->toDateString()],
|
||
[
|
||
'status' => 'base_imported',
|
||
'base_filename' => $file !== '' ? basename($file) : null,
|
||
'base_rows' => $baseRows,
|
||
'delivery_rows' => $deliveryRows,
|
||
'matched_rows' => $matched,
|
||
'last_error' => null,
|
||
'updated_at' => $now,
|
||
'created_at' => $now,
|
||
],
|
||
);
|
||
|
||
$this->info(sprintf(
|
||
'%s 基数导入完成:基表 %d 行 + 代发仓 %d 行,合计 %d 行,其中 %d 行在进销存总表匹配到数据。',
|
||
$month->format('Y-m'),
|
||
$baseRows,
|
||
$deliveryRows,
|
||
count($payload),
|
||
$matched,
|
||
));
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/** @return array<string,array{warehouse_short_name:string,product_code:string,opening_quantity:string,purchase_in:string,sales_out:string,category:string}> */
|
||
private function inventoryAggregate(string $connection, CarbonImmutable $month): array
|
||
{
|
||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||
->where('source_month', $month->toDateString())
|
||
->value('active_generation');
|
||
if (! $generation) {
|
||
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
|
||
}
|
||
|
||
$mappingTable = (new InventoryWarehouseNameMapping)->getConnection()->getDatabaseName()
|
||
. '.' . DB::connection((new InventoryWarehouseNameMapping)->getConnectionName())->getTablePrefix()
|
||
. (new InventoryWarehouseNameMapping)->getTable();
|
||
|
||
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
|
||
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,
|
||
sum(r.purchase_in) as purchase_in,
|
||
sum(r.sales_out) as sales_out, max(r.category) as category
|
||
from inventory_report_authoritative_rows r
|
||
join {$mappingTable} m
|
||
on m.warehouse_provider = r.warehouse_provider
|
||
and m.warehouse = r.warehouse
|
||
and m.company_id = 1
|
||
and m.deleted_at is null
|
||
where r.generation = ?
|
||
and m.warehouse_short_name not like '%不看%'
|
||
and r.warehouse like '%主仓%'
|
||
and m.warehouse_short_name <> '南昌樵舍仓'
|
||
group by m.warehouse_short_name, r.product_code";
|
||
|
||
$aggregate = [];
|
||
foreach (DB::connection($connection)->select($sql, [$generation]) as $row) {
|
||
$aggregate[$row->short_name . "\0" . mb_strtoupper(trim((string) $row->product_code))] = [
|
||
'warehouse_short_name' => (string) $row->short_name,
|
||
// 商品编码统一按大写建索引,避免与表1/普通商品资料大小写不一致导致匹配不上。
|
||
'product_code' => mb_strtoupper(trim((string) $row->product_code)),
|
||
'opening_quantity' => (string) $row->opening_quantity,
|
||
'purchase_in' => (string) $row->purchase_in,
|
||
'sales_out' => (string) $row->sales_out,
|
||
'category' => (string) ($row->category ?? ''),
|
||
];
|
||
}
|
||
|
||
return $aggregate;
|
||
}
|
||
|
||
/** @return \Generator<int,array{barcode_warehouse:string,warehouse_short_name:string,product_code:string,opening_unit_price:?float}> */
|
||
private function readBaseFile(string $file): \Generator
|
||
{
|
||
$reader = IOFactory::createReaderForFile($file);
|
||
$reader->setReadDataOnly(true);
|
||
$spreadsheet = $reader->load($file);
|
||
$sheet = $spreadsheet->getSheetByName('7月份期初基数') ?? $spreadsheet->getSheet(0);
|
||
|
||
foreach ($sheet->getRowIterator(3) as $row) {
|
||
$cells = [];
|
||
foreach ($row->getCellIterator('A', 'P') as $cell) {
|
||
$cells[] = $cell->getFormattedValue();
|
||
}
|
||
$shortName = trim((string) ($cells[2] ?? ''));
|
||
$productCode = trim((string) ($cells[4] ?? ''));
|
||
if ($shortName === '' || $productCode === '') {
|
||
continue;
|
||
}
|
||
$price = trim((string) ($cells[7] ?? ''));
|
||
$previousStatus = trim((string) ($cells[15] ?? ''));
|
||
|
||
yield [
|
||
// 条码+仓库统一为「仓库简称 + 商品编码」,不再沿用表1 里的单元格写法。
|
||
'barcode_warehouse' => $shortName . $productCode,
|
||
'warehouse_short_name' => $shortName,
|
||
'product_code' => mb_strtoupper($productCode),
|
||
// 原始价格保留源表精度(不提前舍入),保证加权平均单价与手工表一致;页面展示仍按两位。
|
||
'opening_unit_price' => $price === '' ? null : (float) str_replace(',', '', $price),
|
||
'previous_pricing_status' => $previousStatus === '' ? null : $previousStatus,
|
||
];
|
||
}
|
||
}
|
||
|
||
private function isDeliveryWarehouse(string $shortName): bool
|
||
{
|
||
return str_contains($shortName, '代发');
|
||
}
|
||
|
||
/**
|
||
* 南昌樵舍仓的采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
|
||
*
|
||
* @return array<string,string>
|
||
*/
|
||
private function jiangxiDeliveryPurchase(string $connection, CarbonImmutable $month): array
|
||
{
|
||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||
->where('source_month', $month->toDateString())
|
||
->value('active_generation');
|
||
if (! $generation) {
|
||
return [];
|
||
}
|
||
|
||
$map = [];
|
||
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
|
||
->where('generation', $generation)
|
||
->where('warehouse', '简尚-江西仓送货主仓')
|
||
->selectRaw('product_code, sum(purchase_in) as quantity')
|
||
->groupBy('product_code')
|
||
->get() as $row) {
|
||
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* 南昌樵舍仓的期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
|
||
*
|
||
* @return array<string,string>
|
||
*/
|
||
private function nanchangOpeningQuantity(string $connection, CarbonImmutable $month): array
|
||
{
|
||
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||
->where('source_month', $month->toDateString())
|
||
->value('active_generation');
|
||
if (! $generation) {
|
||
return [];
|
||
}
|
||
|
||
$map = [];
|
||
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
|
||
->where('generation', $generation)
|
||
->where('warehouse', '三方-南昌樵舍仓主仓')
|
||
->selectRaw('product_code, sum(opening_quantity) as quantity')
|
||
->groupBy('product_code')
|
||
->get() as $row) {
|
||
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
private function rowKey(CarbonImmutable $month, string $shortName, string $productCode): string
|
||
{
|
||
return sha1($month->format('Y-m') . '|' . $shortName . '|' . $productCode);
|
||
}
|
||
}
|