CaiWuXiTong/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php

1052 lines
44 KiB
PHP

<?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 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 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): 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), 100);
$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);
$movementPairs = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
->where('h.occurred_at', '>=', $rangeStart)
->where('h.occurred_at', '<', $rangeEnd)
->select(['h.wms_co_id', 'i.sku_code']);
$inventoryPairs = $this->connection->table(self::INVENTORY_TABLE)
->where('generation', $inventoryGeneration)
->whereNotNull('sku_code')->where('sku_code', '!=', '')
->select(['wms_co_id', 'sku_code'])
->union($movementPairs);
$anchors = $this->connection->query()->fromSub($inventoryPairs, 'pairs')
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
$join->on('inventory.wms_co_id', '=', 'pairs.wms_co_id')
->on('inventory.sku_code', '=', 'pairs.sku_code')
->where('inventory.generation', '=', $inventoryGeneration);
})
->select(['pairs.wms_co_id', 'pairs.sku_code'])
->selectRaw('COALESCE(inventory.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)
->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',
]);
$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.sku_code', '=', 'a.sku_code');
})
->groupBy(['a.wms_co_id', 'a.sku_code', 'a.snapshot_qty', 'a.snapshot_at'])
->select(['a.wms_co_id', 'a.sku_code'])
->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');
$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');
$query = $this->connection->query()->fromSub($monthly, 'm')
->leftJoinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'm.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', '=', 'm.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(['m.*', 's.category', 'w.name as warehouse_name']);
$this->containsFilter($query, 'm.sku_code', $filters['product_code'] ?? '');
$this->containsFilter($query, 's.category', $filters['category'] ?? '');
$this->containsFilter($query, 'w.name', $filters['warehouse_provider'] ?? '');
$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}('m.' . $column, '<>', 0);
}
});
$total = (clone $query)->count();
$rows = $query->orderBy('w.name')->orderBy('m.sku_code')->forPage($page, $perPage)->get()
->map(static function (object $row) use ($period): object {
$warehouse = trim((string) ($row->warehouse_name ?? ''));
return (object) [
'owner' => (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
),
'month' => Carbon::parse($period['start'])->toDateString(),
'warehouse_provider' => $warehouse,
'warehouse' => $warehouse,
'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 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) . '%';
}
}