CaiWuXiTong/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php

1146 lines
47 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 Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Generator;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;
/**
* 三方仓进销存的共享业务镜像只读查询。
*
* 已导入的历史月份直接读取权威日明细;后续月份从每个 SKU 的固定
* 月末锚点按已确认流水前推。未导入锚点时保留实时库存兼容逻辑。
*/
class ThirdPartyWarehouseStorageMirrorRepository
{
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage';
public const WAREHOUSE_ID = 15045364;
private const STATE_TABLE = 'third_party_warehouse_sync_states';
private const HEADER_TABLE = 'third_party_warehouse_inout_headers';
private const ITEM_TABLE = 'third_party_warehouse_inout_items';
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
private const SUBWAREHOUSE_INVENTORY_TABLE = 'third_party_warehouse_inventory_subwarehouse_snapshots';
private const AUTHORITATIVE_STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
private const AUTHORITATIVE_ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
private const AUTHORITATIVE_ROW_TABLE = 'third_party_warehouse_authoritative_rows';
private const AUTHORITATIVE_DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor';
private const PRODUCT_TABLE = 'ops_jst_sku';
private const WAREHOUSE_TABLE = 'ops_jst_warehouse_info';
private const GENERIC_STATE_TABLE = 'business_mirror_table_states';
private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku';
private const WAREHOUSE_DATASET_KEY = 'operations_replica.rpt.jst_warehouseInfo';
public const START_DATE = '2026-08-01';
/** @var array<string,string> */
private const MOVEMENT_COLUMNS = [
'processing_in' => '加工进仓',
'processing_out' => '加工出仓',
'purchase_in' => '采购进仓',
'purchase_return' => '采购退货',
'other_in' => '其它进仓',
'other_return' => '其它退货',
'sales_out' => '销售出仓',
'sales_return' => '销售退货',
'stocktake' => '盘点',
'transfer_out' => '调拨出',
'transfer_in' => '调拨入',
'other_out' => '其它出库',
];
/** @var array<int,string> */
private const SUBWAREHOUSE_NAMES = [
1 => '主仓',
2 => '销退仓',
3 => '进货仓',
4 => '次品仓',
6 => '自定义仓1',
7 => '自定义仓2',
8 => '自定义仓3',
];
/** @var list<string> */
private const OUTPUT_COLUMNS = [
'owner', 'date', 'sku_code', 'image_url', 'product_name', 'short_name',
'supplier', 'category', 'virtual_category', 'supplier_style_no',
'color_spec', 'brand', 'unit', 'opening_qty', 'closing_qty',
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
'transfer_out', 'transfer_in', 'opening_aux', 'other_out',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
public function available(): bool
{
try {
$schema = Schema::connection($this->connection->getName());
foreach ([
self::STATE_TABLE,
self::HEADER_TABLE,
self::ITEM_TABLE,
self::INVENTORY_TABLE,
self::PRODUCT_TABLE,
self::GENERIC_STATE_TABLE,
] as $table) {
if (! $schema->hasTable($table)) {
return false;
}
}
return $schema->hasColumn(self::ITEM_TABLE, 'qty_type')
&& $schema->hasColumn(self::HEADER_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::ITEM_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::INVENTORY_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::INVENTORY_TABLE, 'captured_at');
} catch (Throwable) {
return false;
}
}
public function ready(): bool
{
$state = $this->state();
return is_array($state)
&& ($state['initial_completed_at'] ?? null) !== null
&& $this->generation($state, 'active_header_generation') !== null
&& $this->generation($state, 'active_item_generation') !== null
&& $this->generation($state, 'active_inventory_generation') !== null
&& $this->dependenciesReady();
}
public function allWarehousesReady(): bool
{
$state = $this->state();
return $this->ready()
&& (int) ($state['scope_version'] ?? 1) >= 2
&& $this->warehouseDependencyReady();
}
/** @return array<string,mixed>|null */
public function state(): ?array
{
if (! $this->available()) {
return null;
}
try {
$row = $this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->first();
return $row === null ? null : (array) $row;
} catch (Throwable) {
return null;
}
}
/**
* @param array<string,mixed>|null $state
* @return array{code:string,last_succeeded_at:?string,lag_minutes:?int}
*/
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
{
$state ??= $this->state();
if (! is_array($state)) {
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
}
$lastSucceededAt = $this->timestamp($state['last_succeeded_at'] ?? null);
$lastSucceeded = $lastSucceededAt === null
? null
: Carbon::createFromTimestamp($lastSucceededAt)->toDateTimeString();
$lagMinutes = $lastSucceededAt === null
? null
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceededAt) / 60);
$status = (string) ($state['status'] ?? 'pending');
if (($state['initial_completed_at'] ?? null) === null) {
$code = $status === 'failed'
? 'initial_failed'
: ($status === 'running' ? 'initializing' : 'pending');
} elseif (! $this->dependenciesReady()) {
$code = 'dependency_pending';
} elseif ($status === 'failed') {
$code = 'failed';
} elseif ($status === 'running') {
$code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing';
} elseif ($lagMinutes === null
|| $lagMinutes > max((int) config(
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes',
1560,
), 1)) {
$code = 'delayed';
} else {
$code = 'healthy';
}
return [
'code' => $code,
'last_succeeded_at' => $lastSucceeded,
'lag_minutes' => $lagMinutes,
];
}
/**
* @param array<string,mixed> $filters
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,int|float>,state:array<string,mixed>,sync:array<string,mixed>}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$state = $this->requireReadyState();
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->filteredReportQuery($filters, $state);
$summary = $this->summarizeQuery($this->filteredReportQuery(
$filters,
$state,
$this->hasProductFilters($filters),
));
$rows = $query->orderBy('report_date')->orderBy('sku_code')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
return [
'data' => $rows,
'total' => $summary['count'],
'summary' => $summary,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
/**
* 商品价格管理的进销存报表与三方仓共用同一套已确认流水、
* 库存锚点和业务类型口径,只在这里将日明细再汇总为“商品 + 仓库 + 月”。
*
* @param array<string,mixed> $filters
* @return array{data:list<object>,total:int,state:array<string,mixed>,sync:array<string,mixed>}
*/
public function paginateMonthlyInventoryReport(
array $filters,
int $page,
int $perPage,
bool $includeTotal = true,
?array $state = null,
): array
{
$state ??= $this->requireReadyState();
if ((int) ($state['scope_version'] ?? 1) < 2) {
throw new RuntimeException('inventory_report_all_warehouses_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 5000);
$period = $this->period([
'dimension' => 'month',
'period' => $filters['month'] ?? '',
]);
$headerGeneration = $this->generation($state, 'active_header_generation');
$itemGeneration = $this->generation($state, 'active_item_generation');
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
throw new RuntimeException('inventory_report_mirror_not_ready');
}
$anchorAt = $this->stateAnchor($state);
$rangeStart = min($period['start'], $anchorAt);
$rangeEnd = max($period['end'], $anchorAt);
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')->whereNotNull('sku_id')->where('sku_id', '!=', '')
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$latestWarehouses = $this->connection->table(self::WAREHOUSE_TABLE)
->whereNotNull('wms_co_id')->groupBy('wms_co_id')
->select('wms_co_id')->selectRaw('MAX(Id) AS Id');
$inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE)
->where('generation', $inventoryGeneration)
->where('qty', '!=', 0)
->whereNotNull('sku_code')->where('sku_code', '!=', '')
->select(['wms_co_id', 'wh_id', 'sku_code', 'captured_at'])
->selectRaw('qty AS snapshot_qty');
$candidates = $this->connection->query()->fromSub($inventoryDimensions, 'pairs')
->select(['pairs.wms_co_id', 'pairs.sku_code', 'pairs.wh_id']);
$this->containsFilter($candidates, 'pairs.sku_code', $filters['product_code'] ?? '');
$warehouseFilter = trim((string) ($filters['warehouse_provider'] ?? ''));
if ($warehouseFilter !== '') {
$warehouseIds = $this->connection->query()->fromSub(
clone $latestWarehouses,
'filter_latest_warehouse',
)->join(self::WAREHOUSE_TABLE . ' as filter_warehouse', function ($join): void {
$join->on('filter_warehouse.wms_co_id', '=', 'filter_latest_warehouse.wms_co_id')
->on('filter_warehouse.Id', '=', 'filter_latest_warehouse.Id');
});
$this->containsFilter($warehouseIds, 'filter_warehouse.name', $warehouseFilter);
$candidates->whereIn('pairs.wms_co_id', $warehouseIds->select('filter_warehouse.wms_co_id'));
}
$categoryFilter = trim((string) ($filters['category'] ?? ''));
if ($categoryFilter !== '') {
$categorySkus = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')
->whereNotNull('sku_id')->where('sku_id', '!=', '')
->select('sku_id')->distinct();
$this->containsFilter($categorySkus, 'category', $categoryFilter);
$candidates->whereIn('pairs.sku_code', $categorySkus);
}
$total = $includeTotal ? (clone $candidates)->count() : 0;
$pagePairs = (clone $candidates)
->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('pairs.sku_code')
->forPage($page, $perPage);
$pairRows = (clone $pagePairs)->get();
if ($pairRows->isEmpty()) {
return [
'data' => [],
'total' => $total,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
$dimensionedPagePairs = $this->connection->query()->fromSub($pagePairs, 'pairs')
->leftJoinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'pairs.sku_code')
->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest_product.sku_id')
->on('s.autoid', '=', 'latest_product.autoid');
})
->leftJoinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'pairs.wms_co_id')
->leftJoin(self::WAREHOUSE_TABLE . ' as w', function ($join): void {
$join->on('w.wms_co_id', '=', 'latest_warehouse.wms_co_id')
->on('w.Id', '=', 'latest_warehouse.Id');
})
->select(['pairs.wms_co_id', 'pairs.wh_id', 'pairs.sku_code', 's.category', 'w.name as warehouse_name']);
$anchors = $this->connection->query()->fromSub($dimensionedPagePairs, 'pairs')
->leftJoinSub(clone $inventoryDimensions, 'inventory', function ($join): void {
$join->on('inventory.wms_co_id', '=', 'pairs.wms_co_id')
->on('inventory.sku_code', '=', 'pairs.sku_code')
->on('inventory.wh_id', '=', 'pairs.wh_id');
})
->select(['pairs.wms_co_id', 'pairs.wh_id', 'pairs.sku_code', 'pairs.category', 'pairs.warehouse_name'])
->selectRaw('COALESCE(inventory.snapshot_qty, 0) AS snapshot_qty')
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
$movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
->where('h.occurred_at', '>=', $rangeStart)
->where('h.occurred_at', '<', $rangeEnd)
->whereIn('h.wms_co_id', $pairRows->pluck('wms_co_id')->unique()->values()->all())
->whereIn('i.sku_code', $pairRows->pluck('sku_code')->unique()->values()->all())
->select([
'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code',
'i.qty as movement_qty', 'i.qty_type as movement_qty_type',
])
->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id');
$signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))';
$balanceAt = static fn (string $target): array => [
'a.snapshot_qty + COALESCE(SUM(CASE '
. 'WHEN a.snapshot_at < ? AND mv.occurred_at >= a.snapshot_at AND mv.occurred_at < ? THEN ' . $signed . ' '
. 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signed . ' '
. 'ELSE 0 END), 0)',
[$target, $target, $target, $target],
];
[$openingSql, $openingBindings] = $balanceAt($period['start']);
[$closingSql, $closingBindings] = $balanceAt($period['end']);
$monthly = $this->connection->query()->fromSub($anchors, 'a')
->leftJoinSub($movements, 'mv', function ($join): void {
$join->on('mv.wms_co_id', '=', 'a.wms_co_id')
->on('mv.wh_id', '=', 'a.wh_id')
->on('mv.sku_code', '=', 'a.sku_code');
})
->groupBy([
'a.wms_co_id', 'a.wh_id', 'a.sku_code', 'a.category', 'a.warehouse_name',
'a.snapshot_qty', 'a.snapshot_at',
])
->select(['a.wms_co_id', 'a.wh_id', 'a.sku_code', 'a.category', 'a.warehouse_name'])
->selectRaw($openingSql . ' AS opening_qty', $openingBindings)
->selectRaw($closingSql . ' AS closing_qty', $closingBindings);
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
$monthly->selectRaw(
'COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? '
. 'AND mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0) AS ' . $column,
[$period['start'], $period['end'], $type],
);
}
$monthly->selectRaw('0 AS opening_aux');
$rows = $this->connection->query()->fromSub($monthly, 'm')
->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code')->get()
->map(static function (object $row) use ($period): object {
$warehouse = trim((string) ($row->warehouse_name ?? ''));
$subwarehouseId = (int) ($row->wh_id ?? 0);
$subwarehouse = self::SUBWAREHOUSE_NAMES[$subwarehouseId]
?? '其他子仓(' . $subwarehouseId . ')';
return (object) [
'owner' => (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
),
'month' => Carbon::parse($period['start'])->toDateString(),
'warehouse_provider' => $warehouse,
'warehouse' => $subwarehouse,
'product_code' => (string) ($row->sku_code ?? ''),
'category' => (string) ($row->category ?? ''),
'opening_quantity' => $row->opening_qty,
'closing_quantity' => $row->closing_qty,
'processing_in' => $row->processing_in,
'processing_out' => $row->processing_out,
'purchase_in' => $row->purchase_in,
'purchase_return' => $row->purchase_return,
'other_in' => $row->other_in,
'other_return' => $row->other_return,
'sales_out' => $row->sales_out,
'sales_return' => $row->sales_return,
'stocktake' => $row->stocktake,
'transfer_out' => $row->transfer_out,
'transfer_in' => $row->transfer_in,
'opening' => $row->opening_aux,
'other_out' => $row->other_out,
];
})->all();
return [
'data' => $rows,
'total' => $total,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
/**
* @param array<string,mixed> $filters
* @return Generator<int,object>
*/
public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = 2000): Generator
{
$batchSize = min(max($batchSize, 1), 5000);
$page = 1;
$exported = 0;
$total = null;
$state = $this->requireReadyState();
while (true) {
$result = $this->paginateMonthlyInventoryReport(
$filters,
$page,
$batchSize,
$total === null,
$state,
);
if ($total === null) {
$total = (int) ($result['total'] ?? 0);
}
$rows = $result['data'] ?? [];
if ($rows === []) {
return;
}
foreach ($rows as $row) {
yield $row;
}
$exported += count($rows);
if ($exported >= $total || count($rows) < $batchSize) {
return;
}
$page++;
}
}
/**
* @param array<string,mixed> $filters
* @return array{count:int,opening_qty:float,closing_qty:float}
*/
public function summary(array $filters): array
{
$state = $this->requireReadyState();
return $this->summarizeQuery($this->filteredReportQuery(
$filters,
$state,
$this->hasProductFilters($filters),
));
}
/**
* @param array<string,mixed> $filters
* @return array{suppliers:list<string>,categories:list<string>}
*/
public function filterOptions(array $filters = []): array
{
$state = $this->requireReadyState();
$query = $this->filteredReportQuery($filters, $state);
$suppliers = (clone $query)->whereNotNull('supplier')->where('supplier', '!=', '')
->distinct()->orderBy('supplier')->pluck('supplier')
->map(static fn (mixed $value): string => (string) $value)->all();
$categories = (clone $query)->whereNotNull('category')->where('category', '!=', '')
->distinct()->orderBy('category')->pluck('category')
->map(static fn (mixed $value): string => (string) $value)->all();
return ['suppliers' => $suppliers, 'categories' => $categories];
}
/**
* @param array<string,mixed> $filters
* @return Generator<int,array<string,mixed>>
*/
public function exportRows(
array $filters,
int $batchSize = 500,
bool $includeProductDetails = true,
): Generator
{
$state = $this->requireReadyState();
$batchSize = min(max($batchSize, 1), 5000);
$lastDate = null;
$lastSku = null;
while (true) {
// Settlement only needs quantities and SKU/date fields. Skipping the
// large product catalogue join keeps the page calculation bounded
// while the normal CSV export retains product details.
$query = $this->filteredReportQuery($filters, $state, $includeProductDetails);
if ($lastDate !== null && $lastSku !== null) {
$query->where(function (Builder $cursor) use ($lastDate, $lastSku): void {
$cursor->where('report_date', '>', $lastDate)
->orWhere(function (Builder $sameDate) use ($lastDate, $lastSku): void {
$sameDate->where('report_date', $lastDate)
->where('sku_code', '>', $lastSku);
});
});
}
$rows = $query->orderBy('report_date')->orderBy('sku_code')->limit($batchSize)->get();
if ($rows->isEmpty()) {
return;
}
foreach ($rows as $row) {
yield $this->mapRow($row);
}
$lastDate = (string) $rows->last()->report_date;
$lastSku = (string) $rows->last()->sku_code;
if ($rows->count() < $batchSize) {
return;
}
}
}
/** @param array<string,mixed> $state */
private function filteredReportQuery(
array $filters,
array $state,
bool $includeProductDetails = true,
): Builder
{
$query = $this->connection->query()->fromSub(
$this->reportQuery($filters, $state, $includeProductDetails),
'report',
);
$this->containsFilter($query, 'sku_code', $filters['sku_code'] ?? '');
$this->containsFilter($query, 'product_name', $filters['product_name'] ?? '');
$this->containsFilter($query, 'supplier', $filters['supplier'] ?? '');
$this->containsFilter($query, 'category', $filters['category'] ?? '');
$nonZero = ['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS)];
$query->where(function (Builder $values) use ($nonZero): void {
foreach ($nonZero as $index => $column) {
$method = $index === 0 ? 'where' : 'orWhere';
$values->{$method}($column, '<>', 0);
}
});
return $query;
}
/** @param array<string,mixed> $state */
private function reportQuery(
array $filters,
array $state,
bool $includeProductDetails,
): Builder
{
$period = $this->period($filters);
$headerGeneration = $this->generation($state, 'active_header_generation');
$itemGeneration = $this->generation($state, 'active_item_generation');
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
$authoritativeAnchor = $this->authoritativeAnchor();
$anchorAt = $authoritativeAnchor['anchor_at'] ?? $this->stateAnchor($state);
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
}
$authoritativeRows = $this->authoritativeReportQuery(
$period,
$authoritativeAnchor,
$includeProductDetails,
);
if ($authoritativeRows !== null) {
return $authoritativeRows;
}
$skuRangeStart = min($period['start'], $anchorAt);
$skuRangeEnd = max($period['end'], $anchorAt);
$movementSkus = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $skuRangeStart)
->where('h.occurred_at', '<', $skuRangeEnd)
->select('i.sku_code');
if ($authoritativeAnchor !== null) {
$skuUnion = $this->connection->table(self::AUTHORITATIVE_ANCHOR_TABLE . ' as inventory_skus')
->where('inventory_skus.generation', $authoritativeAnchor['generation'])
->whereNotNull('inventory_skus.sku_code')
->where('inventory_skus.sku_code', '!=', '')
->select('inventory_skus.sku_code')
->union($movementSkus);
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
->leftJoin(self::AUTHORITATIVE_ANCHOR_TABLE . ' as inventory', function ($join) use ($authoritativeAnchor): void {
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
->where('inventory.generation', '=', $authoritativeAnchor['generation']);
})
->select('sku_set.sku_code')
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
->selectRaw('COALESCE(inventory.anchor_at, ?) AS snapshot_at', [$anchorAt]);
} else {
$skuUnion = $this->connection->table(self::INVENTORY_TABLE . ' as inventory_skus')
->where('inventory_skus.generation', $inventoryGeneration)
->where('inventory_skus.wms_co_id', self::WAREHOUSE_ID)
->whereNotNull('inventory_skus.sku_code')
->where('inventory_skus.sku_code', '!=', '')
->select('inventory_skus.sku_code')
->union($movementSkus);
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
->where('inventory.generation', '=', $inventoryGeneration)
->where('inventory.wms_co_id', '=', self::WAREHOUSE_ID);
})
->select('sku_set.sku_code')
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
}
$dateSkuAnchors = $this->connection->query()
->fromSub($this->dateSeries($period['start'], $period['end']), 'd')
->crossJoinSub($anchors, 'a')
->select([
'd.report_date',
'd.day_start',
'd.day_end',
'a.sku_code',
'a.snapshot_qty',
'a.snapshot_at',
]);
$signedQuantity = '(COALESCE(movement_qty, 0) * COALESCE(movement_qty_type, 0))';
$forwardAnchorOperator = $authoritativeAnchor === null ? '>' : '>=';
$closingExpression = 'da.snapshot_qty + COALESCE(SUM(CASE '
. 'WHEN da.snapshot_at < da.day_end AND mv.occurred_at ' . $forwardAnchorOperator . ' da.snapshot_at '
. 'AND mv.occurred_at < da.day_end THEN ' . $signedQuantity . ' '
. 'WHEN da.snapshot_at >= da.day_end AND mv.occurred_at >= da.day_end '
. 'AND mv.occurred_at < da.snapshot_at THEN -' . $signedQuantity . ' '
. 'ELSE 0 END), 0)';
$allMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $skuRangeStart)
->where('h.occurred_at', '<', $skuRangeEnd)
->select([
'i.sku_code',
'i.qty as movement_qty',
'i.qty_type as movement_qty_type',
'h.occurred_at',
]);
$balances = $this->connection->query()->fromSub($dateSkuAnchors, 'da')
->leftJoinSub($allMovements, 'mv', 'mv.sku_code', '=', 'da.sku_code')
->groupBy(
'da.report_date',
'da.day_start',
'da.day_end',
'da.sku_code',
'da.snapshot_qty',
'da.snapshot_at',
)
->select(['da.report_date', 'da.sku_code'])
->selectRaw($closingExpression . ' AS closing_qty');
$periodMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $period['start'])
->where('h.occurred_at', '<', $period['end'])
->groupBy('i.sku_code')
->groupByRaw('DATE(h.occurred_at)')
->select('i.sku_code')
->selectRaw('DATE(h.occurred_at) AS report_date');
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
$periodMovements->selectRaw(
'COALESCE(SUM(CASE WHEN h.io_type = ? THEN '
. '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0)) ELSE 0 END), 0) AS ' . $column,
[$type],
);
}
$report = $this->connection->query()->fromSub($balances, 'b')
->leftJoinSub($periodMovements, 'm', function ($join): void {
$join->on('m.sku_code', '=', 'b.sku_code')
->on('m.report_date', '=', 'b.report_date');
});
if ($includeProductDetails) {
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')
->whereNotNull('sku_id')->where('sku_id', '!=', '')
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$report->leftJoinSub(
$latestProducts,
'latest_product',
'latest_product.sku_id',
'=',
'b.sku_code',
)->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest_product.sku_id')
->on('s.autoid', '=', 'latest_product.autoid')
->where('s.sku_type', '=', 'normal');
});
}
$movementSelects = [];
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
$movementSelects[$column] = 'COALESCE(m.' . $column . ', 0)';
}
$netMovement = implode(' + ', array_values($movementSelects));
$openingQuantity = '(b.closing_qty - (' . $netMovement . '))';
$owner = (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
);
$report->selectRaw('? AS owner', [$owner])
->addSelect('b.report_date')
->addSelect('b.sku_code');
if ($includeProductDetails) {
$report->addSelect([
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
$this->connection->raw('s.name AS product_name'),
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
$this->connection->raw('s.supplier_name AS supplier'),
$this->connection->raw('s.category AS category'),
$this->connection->raw('s.vc_name AS virtual_category'),
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
$this->connection->raw('s.brand AS brand'),
$this->connection->raw('s.unit AS unit'),
]);
}
$report->selectRaw($openingQuantity . ' AS opening_qty')
->selectRaw('b.closing_qty AS closing_qty');
foreach ($movementSelects as $column => $expression) {
$report->selectRaw($expression . ' AS ' . $column);
}
$report->selectRaw('0 AS opening_aux');
return $report;
}
private function dateSeries(string $start, string $end): Builder
{
$cursor = Carbon::parse($start)->startOfDay();
$through = Carbon::parse($end)->startOfDay();
$dates = null;
while ($cursor < $through) {
$dayStart = $cursor->copy();
$dayEnd = $cursor->copy()->addDay();
$row = $this->connection->query()->selectRaw(
'? AS report_date, ? AS day_start, ? AS day_end',
[$dayStart->toDateString(), $dayStart->toDateTimeString(), $dayEnd->toDateTimeString()],
);
$dates = $dates === null ? $row : $dates->unionAll($row);
$cursor->addDay();
}
if (! $dates instanceof Builder) {
throw new RuntimeException('third_party_warehouse_period_invalid');
}
return $dates;
}
private function confirmedMovements(
string $headerGeneration,
string $itemGeneration,
?int $warehouseId = self::WAREHOUSE_ID,
): Builder
{
$query = $this->connection->table(self::ITEM_TABLE . ' as i')
->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void {
$join->on('h.io_id', '=', 'i.io_id')
->where('h.generation', '=', $headerGeneration);
})
->where('i.generation', $itemGeneration)
->where('h.status', 'Confirmed')
->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS))
->whereNotExists(function (Builder $newer) use ($headerGeneration): void {
$newer->selectRaw('1')->from(self::HEADER_TABLE . ' as newer')
->where('newer.generation', $headerGeneration)
->whereColumn('newer.io_id', 'h.io_id')
->whereColumn('newer.id', '>', 'h.id');
})
->whereNotNull('i.sku_code')->where('i.sku_code', '!=', '');
if ($warehouseId !== null) {
$query->where('h.wms_co_id', $warehouseId);
}
return $query;
}
/**
* @param array<string,mixed> $filters
* @return array{dimension:string,value:string,start:string,end:string,days:int}
*/
private function period(array $filters): array
{
$dimension = ($filters['dimension'] ?? 'month') === 'day' ? 'day' : 'month';
$candidate = trim((string) ($filters['period'] ?? ''));
if ($dimension === 'day') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) !== 1) {
$candidate = trim((string) ($filters['date'] ?? ''));
}
try {
$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) === 1
? Carbon::createFromFormat('!Y-m-d', $candidate)
: now()->startOfDay();
} catch (Throwable) {
$start = now()->startOfDay();
}
if ($start->lt(Carbon::parse(self::START_DATE)->startOfDay())) {
$start = Carbon::parse(self::START_DATE)->startOfDay();
}
return [
'dimension' => 'day',
'value' => $start->format('Y-m-d'),
'start' => $start->format('Y-m-d 00:00:00'),
'end' => $start->copy()->addDay()->format('Y-m-d 00:00:00'),
'days' => 1,
];
}
if (preg_match('/^\d{4}-\d{2}$/', $candidate) !== 1) {
$candidate = trim((string) ($filters['month'] ?? ''));
}
try {
$start = preg_match('/^\d{4}-\d{2}$/', $candidate) === 1
? Carbon::createFromFormat('!Y-m', $candidate)->startOfMonth()
: now()->startOfMonth();
} catch (Throwable) {
$start = now()->startOfMonth();
}
$minimum = Carbon::parse(self::START_DATE)->startOfMonth();
if ($start->lt($minimum)) {
$start = $minimum;
}
return [
'dimension' => 'month',
'value' => $start->format('Y-m'),
'start' => $start->format('Y-m-d 00:00:00'),
'end' => $start->copy()->addMonth()->format('Y-m-d 00:00:00'),
'days' => $start->daysInMonth,
];
}
/** @param array<string,mixed> $state */
private function stateAnchor(array $state): string
{
$timestamp = $this->timestamp($state['last_succeeded_at'] ?? null);
return $timestamp === null
? now()->toDateTimeString()
: Carbon::createFromTimestamp($timestamp)->toDateTimeString();
}
/**
* @param array{generation:string,anchor_at:string,source_period_start:?string,source_period_end:?string}|null $authoritativeAnchor
*/
private function authoritativeReportQuery(
array $period,
?array $authoritativeAnchor,
bool $includeProductDetails,
): ?Builder {
if ($authoritativeAnchor === null) {
return null;
}
try {
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable(self::AUTHORITATIVE_ROW_TABLE)) {
return null;
}
$sourceStart = $authoritativeAnchor['source_period_start'] ?? null;
$sourceEnd = $authoritativeAnchor['source_period_end'] ?? null;
if (! is_string($sourceStart) || ! is_string($sourceEnd)
|| $sourceStart === '' || $sourceEnd === '') {
return null;
}
$sourceStartAt = Carbon::parse($sourceStart)->startOfDay();
$sourceEndAt = Carbon::parse($sourceEnd)->addDay()->startOfDay();
$periodStartAt = Carbon::parse($period['start']);
$periodEndAt = Carbon::parse($period['end']);
if ($periodStartAt->lt($sourceStartAt) || $periodEndAt->gt($sourceEndAt)) {
return null;
}
$rows = $this->connection->table(self::AUTHORITATIVE_ROW_TABLE . ' as b')
->where('b.generation', $authoritativeAnchor['generation'])
->where('b.report_date', '>=', $periodStartAt->toDateString())
->where('b.report_date', '<', $periodEndAt->toDateString());
$owner = (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
);
$rows->selectRaw('? AS owner', [$owner])->addSelect(['b.report_date', 'b.sku_code']);
if ($includeProductDetails) {
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')
->whereNotNull('sku_id')->where('sku_id', '!=', '')
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$rows->leftJoinSub(
$latestProducts,
'latest_product',
'latest_product.sku_id',
'=',
'b.sku_code',
)->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest_product.sku_id')
->on('s.autoid', '=', 'latest_product.autoid')
->where('s.sku_type', '=', 'normal');
})->addSelect([
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
$this->connection->raw('s.name AS product_name'),
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
$this->connection->raw('s.supplier_name AS supplier'),
$this->connection->raw('s.category AS category'),
$this->connection->raw('s.vc_name AS virtual_category'),
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
$this->connection->raw('s.brand AS brand'),
$this->connection->raw('s.unit AS unit'),
]);
}
$rows->addSelect([
'b.opening_qty', 'b.closing_qty', 'b.processing_in', 'b.processing_out',
'b.purchase_in', 'b.purchase_return', 'b.other_in', 'b.other_return',
'b.sales_out', 'b.sales_return', 'b.stocktake', 'b.transfer_out',
'b.transfer_in', 'b.opening_aux', 'b.other_out',
]);
return $rows;
} catch (Throwable) {
return null;
}
}
/** @return array{generation:string,anchor_at:string,source_period_start:?string,source_period_end:?string}|null */
private function authoritativeAnchor(): ?array
{
try {
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable(self::AUTHORITATIVE_STATE_TABLE)
|| ! $schema->hasTable(self::AUTHORITATIVE_ANCHOR_TABLE)) {
return null;
}
$state = $this->connection->table(self::AUTHORITATIVE_STATE_TABLE)
->where('dataset_key', self::AUTHORITATIVE_DATASET_KEY)
->whereNotNull('active_generation')
->whereNotNull('anchor_at')
->first();
if ($state === null) {
return null;
}
$generation = trim((string) $state->active_generation);
$anchorAt = $this->timestamp($state->anchor_at ?? null);
if ($generation === '' || $anchorAt === null) {
return null;
}
return [
'generation' => $generation,
'anchor_at' => Carbon::createFromTimestamp($anchorAt)->toDateTimeString(),
'source_period_start' => isset($state->source_period_start)
? (string) $state->source_period_start : null,
'source_period_end' => isset($state->source_period_end)
? (string) $state->source_period_end : null,
];
} catch (Throwable) {
return null;
}
}
/** @return array<string,mixed> */
private function requireReadyState(): array
{
$state = $this->state();
if (! is_array($state)
|| ($state['initial_completed_at'] ?? null) === null
|| $this->generation($state, 'active_header_generation') === null
|| $this->generation($state, 'active_item_generation') === null
|| $this->generation($state, 'active_inventory_generation') === null
|| ! $this->dependenciesReady()) {
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
}
return $state;
}
private function dependenciesReady(): bool
{
try {
$required = [self::PRODUCT_DATASET_KEY];
$ready = $this->connection->table(self::GENERIC_STATE_TABLE)
->whereIn('dataset_key', $required)
->whereNotNull('initial_completed_at')
->pluck('dataset_key')
->map(static fn (mixed $dataset): string => (string) $dataset)
->all();
return array_diff($required, $ready) === [];
} catch (Throwable) {
return false;
}
}
private function warehouseDependencyReady(): bool
{
try {
if (! $this->connection->getSchemaBuilder()->hasTable(self::WAREHOUSE_TABLE)) {
return false;
}
return $this->connection->table(self::GENERIC_STATE_TABLE)
->where('dataset_key', self::WAREHOUSE_DATASET_KEY)
->whereNotNull('initial_completed_at')
->whereIn('status', ['succeeded', 'running', 'failed'])
->exists();
} catch (Throwable) {
return false;
}
}
/** @param array<string,mixed> $state */
private function generation(array $state, string $key): ?string
{
$value = trim((string) ($state[$key] ?? ''));
return $value === '' ? null : $value;
}
private function timestamp(mixed $value): ?int
{
if ($value instanceof CarbonInterface) {
return $value->getTimestamp();
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value)->getTimestamp();
} catch (Throwable) {
return null;
}
}
/**
* @return array{count:int,opening_qty:float,closing_qty:float}
*/
private function summarizeQuery(Builder $query): array
{
$row = $query->selectRaw(
'COUNT(*) AS row_count, '
. 'COALESCE(SUM(opening_qty), 0) AS opening_qty, '
. 'COALESCE(SUM(closing_qty), 0) AS closing_qty'
)->first();
return [
'count' => (int) ($row->row_count ?? 0),
'opening_qty' => round((float) ($row->opening_qty ?? 0), 4),
'closing_qty' => round((float) ($row->closing_qty ?? 0), 4),
];
}
/** @return array<string,mixed> */
private function mapRow(object $row): array
{
$mapped = [];
foreach (self::OUTPUT_COLUMNS as $column) {
$mapped[$column] = $column === 'date'
? ($row->report_date ?? null)
: ($row->{$column} ?? null);
}
foreach (['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS), 'opening_aux'] as $column) {
$mapped[$column] = round((float) ($mapped[$column] ?? 0), 4);
}
return $mapped;
}
private function containsFilter(Builder $query, string $column, mixed $value): void
{
$value = trim((string) $value);
if ($value !== '') {
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
}
}
/** @param array<string,mixed> $filters */
private function hasProductFilters(array $filters): bool
{
foreach (['product_name', 'supplier', 'category'] as $filter) {
if (trim((string) ($filters[$filter] ?? '')) !== '') {
return true;
}
}
return false;
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
}