*/ 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 */ private const SUBWAREHOUSE_SUFFIXES = [ 1 => '主仓', 2 => '销退仓', 3 => '进货仓', 4 => '次品仓', 6 => '自定义仓1', 7 => '自定义仓2', 8 => '自定义仓3', ]; /** * 聚水潭“按商品、按仓库、按月”导出中使用的实际子仓名。 * 其余仓库按“仓储方 + 子仓后缀”生成,不再只显示通用类型名。 * * @var array> */ private const SPECIAL_SUBWAREHOUSE_NAMES = [ '常州简尚家居有限公司' => [ 1 => '配件单仓', ], '简尚-横林二仓' => [ 6 => '客退临时上架仓', 7 => '数量校验仓', ], '简尚-新苏北仓' => [ 6 => '新苏北客退上架仓', ], '简尚-睢宁仓' => [ 6 => '睢宁客退上架仓', ], '简尚-河南洛宁仓' => [ 6 => '河南洛宁客退上架仓', ], '简尚-山东青岗集仓' => [ 7 => '青岗集客退上架仓', ], ]; /** @var list */ 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|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|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 $filters * @return array{data:list>,total:int,summary:array,state:array,sync:array} */ 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 $filters * @return array{data:list,total:int,state:array,sync:array} */ public function paginateMonthlyInventoryReport( array $filters, int $page, int $perPage, bool $includeTotal = true, ?array $state = null, ?array $sourceCounts = null, ?int $queryTimeoutSeconds = null, ?Collection $prefetchedMovementRows = null, bool $refreshSourceCounts = false, ): 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), $prefetchedMovementRows === null ? 5000 : self::REPORT_EXPORT_BATCH_SIZE); $queryTimeoutSeconds ??= self::REPORT_QUERY_TIMEOUT_SECONDS; $period = $this->inventoryReportPeriod((string) ($filters['month'] ?? '')); // 已导入的当前月份是对账基准,直接返回工作簿中的完整月度明细。 // 这样不会再把同月镜像流水重复叠加到权威的各类进出数量上。 $currentAuthority = $this->currentMonthlyInventoryAuthority($period); if ($currentAuthority !== null) { return $this->paginateCurrentMonthlyInventoryAuthority( $period, $filters, $page, $perPage, $includeTotal, $state, $queryTimeoutSeconds, ); } // 没有当前月权威数据时,保留原有的上月期末结转和已确认流水滚算。 $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'); } $authority = $this->previousMonthlyInventoryAuthority($period); $authorityIsCurrentMonth = false; $resolveRollForward = fn (): ?array => $authority === null ? null : $this->monthlyInventoryRollForward( $period, $filters, $authority, $headerGeneration, $itemGeneration, $inventoryGeneration, $page, $perPage, $includeTotal, $state, $authorityIsCurrentMonth, $sourceCounts, $queryTimeoutSeconds, $prefetchedMovementRows, $refreshSourceCounts, ); $rolledForward = $includeTotal && $perPage <= 100 && $authority !== null ? $this->rememberMonthlyInventoryReport( $this->monthlyInventoryReportCacheKey( $period, $filters, $authority, $headerGeneration, $itemGeneration, $inventoryGeneration, $page, $perPage, ), $resolveRollForward, ) : $resolveRollForward(); if ($rolledForward !== null) { $rolledForward['state'] = $state; $rolledForward['sync'] = $this->syncStatus($state); return $rolledForward; } $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 as inventory_sku_code', 'captured_at']) ->selectRaw('qty AS snapshot_qty'); $candidates = $this->connection->query()->fromSub($inventoryDimensions, 'pairs') ->joinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'pairs.inventory_sku_code') ->join(self::PRODUCT_TABLE . ' as s', function ($join): void { $join->on('s.sku_id', '=', 'latest_product.sku_id') ->on('s.autoid', '=', 'latest_product.autoid'); }) ->select([ 'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code', 's.sku_id as sku_code', 's.category', ]); $productCode = trim((string) ($filters['product_code'] ?? '')); if ($productCode !== '') { $candidates->where('s.sku_id', $productCode); } $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 !== '') { $this->containsFilter($candidates, 's.category', $categoryFilter); } $total = $includeTotal ? $this->boundedCount(clone $candidates, $queryTimeoutSeconds) : 0; $pagePairs = (clone $candidates) ->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('s.sku_id') ->forPage($page, $perPage); $pairRows = $this->boundedGet(clone $pagePairs, $queryTimeoutSeconds); if ($pairRows->isEmpty()) { return [ 'data' => [], 'total' => $total, 'state' => $state, 'sync' => $this->syncStatus($state), ]; } $dimensionedPagePairs = $this->connection->query()->fromSub($pagePairs, 'pairs') ->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.inventory_sku_code', 'pairs.sku_code', 'pairs.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.inventory_sku_code', '=', 'pairs.inventory_sku_code') ->on('inventory.wh_id', '=', 'pairs.wh_id'); }) ->select([ 'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code', '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('inventory_sku_code')->unique()->values()->all()) ->select([ 'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code as inventory_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.inventory_sku_code', '=', 'a.inventory_sku_code'); }) ->groupBy([ 'a.wms_co_id', 'a.wh_id', 'a.inventory_sku_code', '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'); $monthlyRows = $this->connection->query()->fromSub($monthly, 'm') ->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code'); $rows = $this->boundedGet($monthlyRows, $queryTimeoutSeconds) ->map(function (object $row) use ($period): object { $warehouse = trim((string) ($row->warehouse_name ?? '')); $subwarehouseId = (int) ($row->wh_id ?? 0); $subwarehouse = $this->subwarehouseName($warehouse, $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), ]; } /** * Read one authoritative month without joining the movement mirror. * The imported workbook already contains the opening balance, every movement * column and the closing balance at the requested warehouse/SKU grain. * * @param array{start:string,end:string} $period * @param array $filters * @param array $state * @return array{data:list,total:int,state:array,sync:array} */ private function paginateCurrentMonthlyInventoryAuthority( array $period, array $filters, int $page, int $perPage, bool $includeTotal, array $state, ?int $queryTimeoutSeconds, ): array { $query = $this->monthlyInventoryAuthoritativeQuery($period, $filters); if ($query === null) { return [ 'data' => [], 'total' => 0, 'state' => $state, 'sync' => $this->syncStatus($state), ]; } $total = $includeTotal ? $this->boundedCount(clone $query, $queryTimeoutSeconds) : 0; $rows = $this->boundedGet( (clone $query) ->orderBy('warehouse_provider') ->orderBy('warehouse') ->orderBy('product_code') ->forPage($page, $perPage), $queryTimeoutSeconds, )->map(static function (object $row): object { return (object) [ 'owner' => (string) $row->owner, 'month' => Carbon::parse($row->report_month)->toDateString(), 'warehouse_provider' => (string) $row->warehouse_provider, 'warehouse' => (string) $row->warehouse, 'product_code' => (string) $row->product_code, 'category' => (string) ($row->category ?? ''), 'opening_quantity' => $row->opening_quantity, 'closing_quantity' => $row->closing_quantity, '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, 'other_out' => $row->other_out, ]; })->all(); return [ 'data' => $rows, 'total' => $total, 'state' => $state, 'sync' => $this->syncStatus($state), ]; } private function subwarehouseName(string $warehouseProvider, int $subwarehouseId): string { $specialName = self::SPECIAL_SUBWAREHOUSE_NAMES[$warehouseProvider][$subwarehouseId] ?? null; if ($specialName !== null) { return $specialName; } $suffix = self::SUBWAREHOUSE_SUFFIXES[$subwarehouseId] ?? '其他子仓(' . $subwarehouseId . ')'; return $warehouseProvider === '' ? $suffix : $warehouseProvider . $suffix; } /** * @param array $filters * @return Generator */ public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = self::REPORT_EXPORT_BATCH_SIZE): Generator { $batchSize = min(max($batchSize, 1), self::REPORT_EXPORT_BATCH_SIZE); $page = 1; $state = $this->requireReadyState(); $sourceCounts = null; $prefetchedMovementRows = $this->prefetchMonthlyMovementRows($filters, $state); $refreshSourceCounts = true; $exported = 0; $expectedTotal = null; while (true) { $result = $this->paginateMonthlyInventoryReport( $filters, $page, $batchSize, false, $state, $sourceCounts, self::REPORT_EXPORT_QUERY_TIMEOUT_SECONDS, $prefetchedMovementRows, $refreshSourceCounts, ); $refreshSourceCounts = false; if (isset($result['source_counts']) && is_array($result['source_counts'])) { $sourceCounts = $result['source_counts']; $expectedTotal ??= array_sum(array_map('intval', $sourceCounts)); } $rows = $result['data'] ?? []; if ($rows === []) { return; } $rowCount = count($rows); if ($expectedTotal !== null) { $remaining = $expectedTotal - $exported; if ($remaining <= 0) { return; } if ($rowCount > $remaining) { $rows = array_slice($rows, 0, $remaining); $rowCount = count($rows); } } foreach ($rows as $row) { yield $row; } $exported += $rowCount; // Release the previous batch before constructing the next SQL result. unset($rows, $result); if ($rowCount < $batchSize || ($expectedTotal !== null && $exported >= $expectedTotal)) { return; } $page++; } } /** * 用户确认的聚水潭月报保留了“仓储方 + 子仓 + 商品”维度。 * 命中同月权威数据时整月直接读取,不再用当前库存倒推历史子仓余额。 * * @param array{start:string,end:string} $period * @param array $filters */ private function monthlyInventoryAuthoritativeQuery(array $period, array $filters): ?Builder { try { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) || ! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE)) { return null; } $month = Carbon::parse($period['start'])->startOfMonth()->toDateString(); $authority = $this->currentMonthlyInventoryAuthority($period); if ($authority === null) { return null; } $query = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE) ->where('generation', $authority['generation']) ->where('report_month', $month) ->select([ 'owner', 'report_month', 'warehouse_provider', 'warehouse', 'product_code', 'category', 'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out', 'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out', ]); $productCode = trim((string) ($filters['product_code'] ?? '')); if ($productCode !== '') { $query->where('product_code', $productCode); } $this->containsFilter($query, 'warehouse_provider', $filters['warehouse_provider'] ?? ''); $this->containsFilter($query, 'category', $filters['category'] ?? ''); return $query; } catch (Throwable) { return null; } } /** * Every later month starts from the latest authoritative month closing quantity and * rolls confirmed movements forward to the requested month. Current inventory is * deliberately ignored because it cannot reproduce historical opening balances. * * @param array{start:string,end:string} $period * @param array $filters * @param array{generation:string,source_month:string} $authority * @param array $state * @return array{data:list,total:int,state:array,sync:array}|null */ private function monthlyInventoryRollForward( array $period, array $filters, array $authority, string $headerGeneration, string $itemGeneration, string $inventoryGeneration, int $page, int $perPage, bool $includeTotal, array $state, bool $authorityIsCurrentMonth = false, ?array $sourceCounts = null, ?int $queryTimeoutSeconds = null, ?Collection $prefetchedMovementRows = null, bool $refreshSourceCounts = false, ): ?array { try { $queryTimeoutSeconds ??= self::REPORT_QUERY_TIMEOUT_SECONDS; $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'); $authorityRows = $this->connection ->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as authority') ->where('authority.generation', $authority['generation']) ->where('authority.report_month', $authority['source_month']); $productCode = trim((string) ($filters['product_code'] ?? '')); if ($productCode !== '') { $authorityRows->where('authority.product_code', $productCode); } $this->containsFilter($authorityRows, 'authority.warehouse_provider', $filters['warehouse_provider'] ?? ''); $this->containsFilter($authorityRows, 'authority.category', $filters['category'] ?? ''); [$authorityWarehouseIdSql, $authorityWarehouseIdBindings] = $this->subwarehouseIdSql( 'authority.warehouse_provider', 'authority.warehouse', ); $authorityOpeningColumn = $authorityIsCurrentMonth ? 'authority.opening_quantity' : 'authority.closing_quantity'; $authorityCandidates = (clone $authorityRows) ->leftJoin(self::WAREHOUSE_TABLE . ' as authority_warehouse', function ($join): void { $join->on('authority_warehouse.name', '=', 'authority.warehouse_provider'); }) ->leftJoinSub($latestWarehouses, 'authority_latest_warehouse', function ($join): void { $join->on('authority_latest_warehouse.wms_co_id', '=', 'authority_warehouse.wms_co_id') ->on('authority_latest_warehouse.Id', '=', 'authority_warehouse.Id'); })->selectRaw( 'authority.owner AS owner, authority.warehouse_provider AS warehouse_provider, ' . 'authority.warehouse AS source_warehouse, authority_warehouse.wms_co_id AS wms_co_id, ' . $authorityWarehouseIdSql . ' AS wh_id, authority.product_code AS sku_code, ' . 'authority.category AS category, ' . $authorityOpeningColumn . ' AS opening_qty', $authorityWarehouseIdBindings, ); $inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE . ' as inventory') ->where('inventory.generation', $inventoryGeneration) ->where('inventory.qty', '!=', 0) ->whereNotNull('inventory.sku_code')->where('inventory.sku_code', '!=', '') ->joinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'inventory.sku_code') ->join(self::PRODUCT_TABLE . ' as s', function ($join): void { $join->on('s.sku_id', '=', 'latest_product.sku_id') ->on('s.autoid', '=', 'latest_product.autoid'); }) ->joinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'inventory.wms_co_id') ->join(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'); }); if ($productCode !== '') { $inventoryDimensions->where('s.sku_id', $productCode); } $this->containsFilter($inventoryDimensions, 'w.name', $filters['warehouse_provider'] ?? ''); $this->containsFilter($inventoryDimensions, 's.category', $filters['category'] ?? ''); $owner = (string) config( 'upstream_mirrors.third_party_warehouse_storage.owner_name', '常州简尚家居有限公司', ); [$inventoryWarehouseSql, $inventoryWarehouseBindings] = $this->subwarehouseNameSql( 'w.name', 'inventory.wh_id', ); $inventoryDimensions->whereNotExists(function (Builder $prior) use ( $authority, $inventoryWarehouseSql, $inventoryWarehouseBindings, ): void { $prior->selectRaw('1') ->from(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as prior') ->where('prior.generation', $authority['generation']) ->where('prior.report_month', $authority['source_month']) ->whereColumn('prior.product_code', 'inventory.sku_code') ->whereColumn('prior.warehouse_provider', 'w.name') ->whereRaw('prior.warehouse = ' . $inventoryWarehouseSql, $inventoryWarehouseBindings); }); $inventoryDimensions->distinct() ->selectRaw( '? AS owner, w.name AS warehouse_provider, ' . $inventoryWarehouseSql . ' AS source_warehouse, ' . 'inventory.wms_co_id AS wms_co_id, inventory.wh_id AS wh_id, ' . 'inventory.sku_code AS sku_code, s.category AS category, 0 AS opening_qty', array_merge([$owner], $inventoryWarehouseBindings), ); $authorityEnd = Carbon::parse($authority['source_month']) ->addMonth()->startOfMonth()->format('Y-m-d 00:00:00'); $movementStart = $authorityIsCurrentMonth ? $period['start'] : $authorityEnd; $movementWarehouseIdSql = 'COALESCE(NULLIF(h.wh_id, 0), 1)'; $movementPairs = $this->confirmedMovements($headerGeneration, $itemGeneration, null) ->where('h.occurred_at', '>=', $movementStart) ->where('h.occurred_at', '<', $period['end']) ->where('i.qty', '!=', 0) ->where('i.qty_type', '!=', 0) ->select('h.wms_co_id', 'i.sku_code') ->selectRaw($movementWarehouseIdSql . ' AS wh_id') ->distinct(); if ($productCode !== '') { $movementPairs->where('i.sku_code', $productCode); } $movementDimensions = $this->connection->query()->fromSub($movementPairs, 'movement_pair') ->joinSub($latestWarehouses, 'movement_latest_warehouse', 'movement_latest_warehouse.wms_co_id', '=', 'movement_pair.wms_co_id') ->join(self::WAREHOUSE_TABLE . ' as movement_warehouse', function ($join): void { $join->on('movement_warehouse.wms_co_id', '=', 'movement_latest_warehouse.wms_co_id') ->on('movement_warehouse.Id', '=', 'movement_latest_warehouse.Id'); }); $this->containsFilter($movementDimensions, 'movement_warehouse.name', $filters['warehouse_provider'] ?? ''); [$movementWarehouseSql, $movementWarehouseBindings] = $this->subwarehouseNameSql( 'movement_warehouse.name', 'movement_pair.wh_id', ); $movementDimensions->whereNotExists(function (Builder $prior) use ( $authority, $movementWarehouseSql, $movementWarehouseBindings, ): void { $prior->selectRaw('1') ->from(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as prior') ->where('prior.generation', $authority['generation']) ->where('prior.report_month', $authority['source_month']) ->whereColumn('prior.product_code', 'movement_pair.sku_code') ->whereColumn('prior.warehouse_provider', 'movement_warehouse.name') ->whereRaw('prior.warehouse = ' . $movementWarehouseSql, $movementWarehouseBindings); }); if (! $authorityIsCurrentMonth) { $movementDimensions->whereNotExists(function (Builder $snapshot) use ($inventoryGeneration): void { $snapshot->selectRaw('1') ->from(self::SUBWAREHOUSE_INVENTORY_TABLE . ' as matching_inventory') ->where('matching_inventory.generation', $inventoryGeneration) ->where('matching_inventory.qty', '!=', 0) ->whereColumn('matching_inventory.wms_co_id', 'movement_pair.wms_co_id') ->whereRaw('COALESCE(NULLIF(matching_inventory.wh_id, 0), 1) = movement_pair.wh_id') ->whereColumn('matching_inventory.sku_code', 'movement_pair.sku_code'); }); } $movementCount = clone $movementDimensions; if (trim((string) ($filters['category'] ?? '')) === '') { $movementCount->whereExists(function (Builder $product): void { $product->selectRaw('1')->from(self::PRODUCT_TABLE . ' as known_product') ->where('known_product.sku_type', 'normal') ->whereColumn('known_product.sku_id', 'movement_pair.sku_code'); })->selectRaw('1 AS counted'); } $movementDimensions->joinSub($latestProducts, 'movement_latest_product', 'movement_latest_product.sku_id', '=', 'movement_pair.sku_code') ->join(self::PRODUCT_TABLE . ' as movement_product', function ($join): void { $join->on('movement_product.sku_id', '=', 'movement_latest_product.sku_id') ->on('movement_product.autoid', '=', 'movement_latest_product.autoid'); }); $this->containsFilter($movementDimensions, 'movement_product.category', $filters['category'] ?? ''); if (trim((string) ($filters['category'] ?? '')) !== '') { $movementCount = (clone $movementDimensions)->selectRaw('1 AS counted'); } $movementDimensions->selectRaw( '? AS owner, movement_warehouse.name AS warehouse_provider, ' . $movementWarehouseSql . ' AS source_warehouse, ' . 'movement_pair.wms_co_id AS wms_co_id, movement_pair.wh_id AS wh_id, ' . 'movement_pair.sku_code AS sku_code, movement_product.category AS category, 0 AS opening_qty', array_merge([$owner], $movementWarehouseBindings), ); $sources = $authorityIsCurrentMonth ? [ [$authorityCandidates, $authorityCandidates, 'authority.warehouse_provider', 'authority.product_code'], [$movementDimensions, $movementCount, 'movement_warehouse.name', 'movement_pair.sku_code'], ] : [ [$authorityCandidates, $authorityCandidates, 'authority.warehouse_provider', 'authority.product_code'], [$inventoryDimensions, $inventoryDimensions, 'w.name', 'inventory.sku_code'], [$movementDimensions, $movementCount, 'movement_warehouse.name', 'movement_pair.sku_code'], ]; $counts = is_array($sourceCounts) ? array_values($sourceCounts) : []; $total = 0; $countCacheKey = $this->monthlyInventoryReportCacheKey( $period, $filters, $authority, $headerGeneration, $itemGeneration, $inventoryGeneration, 0, 0, ) . ':source-counts'; if ($includeTotal || $counts === [] || $refreshSourceCounts) { $cachedCounts = $refreshSourceCounts ? null : Cache::get($countCacheKey); if (is_array($cachedCounts) && count($cachedCounts) === count($sources)) { $counts = array_values($cachedCounts); } else { foreach ($sources as $index => [, $countQuery]) { $counts[$index] = $this->boundedCount(clone $countQuery, $queryTimeoutSeconds); } Cache::put($countCacheKey, $counts, self::REPORT_CACHE_SECONDS); } } if ($includeTotal) { $total = array_sum($counts); } $offset = ($page - 1) * $perPage; $remaining = $perPage; $candidatePages = []; foreach ($sources as $index => [$candidates, $countQuery, $warehouseOrder, $productOrder]) { if ($remaining === 0) { break; } $sourceTotal = $counts[$index] ?? null; if ($sourceTotal !== null && $offset >= $sourceTotal) { $offset -= $sourceTotal; continue; } $limit = $sourceTotal === null ? $remaining : min($remaining, $sourceTotal - $offset); $candidatePages[] = (clone $candidates) ->orderBy($warehouseOrder) ->orderBy('wh_id') ->orderBy($productOrder) ->offset($offset) ->limit($limit); $remaining -= $limit; $offset = 0; } if ($candidatePages === []) { return [ 'data' => [], 'total' => $total, 'source_counts' => $counts, 'state' => $state, 'sync' => $this->syncStatus($state), ]; } $pageCandidates = array_shift($candidatePages); foreach ($candidatePages as $candidatePage) { $pageCandidates->unionAll($candidatePage); } $pairRows = $this->boundedGet(clone $pageCandidates, $queryTimeoutSeconds); if ($pairRows->isEmpty()) { return [ 'data' => [], 'total' => $total, 'source_counts' => $counts, 'state' => $state, 'sync' => $this->syncStatus($state), ]; } $movementPairs = $pairRows->filter( static fn (object $row): bool => $row->wms_co_id !== null && trim((string) $row->sku_code) !== '', )->unique(static fn (object $row): string => implode(':', [ (string) $row->wms_co_id, (string) $row->wh_id, (string) $row->sku_code, ]))->values(); $movementRows = $prefetchedMovementRows ?? collect(); if ($movementPairs->isNotEmpty()) { if ($prefetchedMovementRows === null) { $movements = $this->aggregateMonthlyMovementQuery( $period, $authorityEnd, $movementStart, $headerGeneration, $itemGeneration, ); $movementRows = $this->boundedGet($movements, $queryTimeoutSeconds)->keyBy( static fn (object $row): string => implode(':', [ (string) $row->wms_co_id, (string) $row->wh_id, (string) $row->sku_code, ]), ); } } $rows = $pairRows->map(function (object $row) use ($movementRows, $period): object { $key = implode(':', [(string) $row->wms_co_id, (string) $row->wh_id, (string) $row->sku_code]); $movement = $movementRows->get($key); $sourceWarehouse = trim((string) ($row->source_warehouse ?? '')); $warehouse = $sourceWarehouse !== '' ? $sourceWarehouse : $this->subwarehouseName( trim((string) $row->warehouse_provider), (int) $row->wh_id, ); $rolledOpening = (float) $row->opening_qty + (float) ($movement->rolled_opening_delta ?? 0); $closingQuantity = $rolledOpening; foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) { $closingQuantity += (float) ($movement->{$column} ?? 0); } return (object) [ 'owner' => $row->owner, 'month' => Carbon::parse($period['start'])->toDateString(), 'warehouse_provider' => $row->warehouse_provider, 'warehouse' => $warehouse, 'product_code' => $row->sku_code, 'category' => $row->category, 'opening_quantity' => $rolledOpening, 'closing_quantity' => $closingQuantity, 'processing_in' => $movement->processing_in ?? 0, 'processing_out' => $movement->processing_out ?? 0, 'purchase_in' => $movement->purchase_in ?? 0, 'purchase_return' => $movement->purchase_return ?? 0, 'other_in' => $movement->other_in ?? 0, 'other_return' => $movement->other_return ?? 0, 'sales_out' => $movement->sales_out ?? 0, 'sales_return' => $movement->sales_return ?? 0, 'stocktake' => $movement->stocktake ?? 0, 'transfer_out' => $movement->transfer_out ?? 0, 'transfer_in' => $movement->transfer_in ?? 0, 'opening' => 0, 'other_out' => $movement->other_out ?? 0, ]; })->all(); return [ 'data' => $rows, 'total' => $total, 'source_counts' => $counts, 'state' => $state, 'sync' => $this->syncStatus($state), ]; } catch (Throwable $exception) { if ($this->isStatementTimeout($exception)) { throw $exception; } report($exception); return null; } } /** * Aggregate the complete movement range once for a full export. The normal * page query intentionally limits movements to the current page, but doing * that for every export batch repeats the same expensive scan thousands of * times. The result is keyed by warehouse, subwarehouse and SKU so each * page can perform an O(1) lookup. * * @param array $filters * @param array $state * @return Collection|null */ private function prefetchMonthlyMovementRows(array $filters, array $state): ?Collection { $period = $this->inventoryReportPeriod((string) ($filters['month'] ?? '')); $headerGeneration = $this->generation($state, 'active_header_generation'); $itemGeneration = $this->generation($state, 'active_item_generation'); if ($headerGeneration === null || $itemGeneration === null) { return null; } $currentAuthority = $this->currentMonthlyInventoryAuthority($period); if ($currentAuthority !== null) { // The current authoritative month already contains all movement // columns; exporting it must not scan the mirror movement tables. return collect(); } $authority = $currentAuthority ?? $this->previousMonthlyInventoryAuthority($period); if ($authority === null) { return null; } $authorityEnd = Carbon::parse($authority['source_month']) ->addMonth()->startOfMonth()->format('Y-m-d 00:00:00'); $movementStart = $currentAuthority !== null ? $period['start'] : $authorityEnd; $query = $this->aggregateMonthlyMovementQuery( $period, $authorityEnd, $movementStart, $headerGeneration, $itemGeneration, ); return $this->boundedGet($query, self::REPORT_EXPORT_AGGREGATE_TIMEOUT_SECONDS)->keyBy( static fn (object $row): string => implode(':', [ (string) $row->wms_co_id, (string) $row->wh_id, (string) $row->sku_code, ]), ); } /** * @param array{start:string,end:string} $period */ private function aggregateMonthlyMovementQuery( array $period, string $authorityEnd, string $movementStart, string $headerGeneration, string $itemGeneration, ): Builder { $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) ->where('h.occurred_at', '>=', $movementStart) ->where('h.occurred_at', '<', $period['end']) ->groupBy(['h.wms_co_id', 'i.sku_code']) ->groupByRaw('COALESCE(NULLIF(h.wh_id, 0), 1)') ->select(['h.wms_co_id', 'i.sku_code']) ->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id'); $signed = '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0))'; $movements->selectRaw( 'COALESCE(SUM(CASE WHEN h.occurred_at >= ? AND h.occurred_at < ? ' . 'THEN ' . $signed . ' ELSE 0 END), 0) AS rolled_opening_delta', [$authorityEnd, $period['start']], ); foreach (self::MOVEMENT_COLUMNS as $column => $type) { $expression = 'COALESCE(SUM(CASE WHEN h.occurred_at >= ? AND h.occurred_at < ? ' . 'AND h.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0)'; $movements->selectRaw($expression . ' AS ' . $column, [$period['start'], $period['end'], $type]); } return $movements; } /** * @param array{start:string,end:string} $period * @param array $filters * @param array{generation:string,source_month:string} $authority */ private function monthlyInventoryReportCacheKey( array $period, array $filters, array $authority, string $headerGeneration, string $itemGeneration, string $inventoryGeneration, int $page, int $perPage, ): string { $normalizedFilters = [ 'product_code' => trim((string) ($filters['product_code'] ?? '')), 'warehouse_provider' => trim((string) ($filters['warehouse_provider'] ?? '')), 'category' => trim((string) ($filters['category'] ?? '')), ]; return 'inventory-report:monthly-page:v10:' . hash('sha256', (string) json_encode([ 'period' => $period, 'filters' => $normalizedFilters, 'authority' => $authority, 'header_generation' => $headerGeneration, 'item_generation' => $itemGeneration, 'inventory_generation' => $inventoryGeneration, 'page' => $page, 'per_page' => $perPage, ], JSON_UNESCAPED_UNICODE)); } /** @param callable():?array $resolver */ private function rememberMonthlyInventoryReport(string $key, callable $resolver): ?array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } try { return Cache::lock($key . ':lock', self::REPORT_CACHE_LOCK_SECONDS) ->block(self::REPORT_CACHE_WAIT_SECONDS, function () use ($key, $resolver): ?array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } $resolved = $resolver(); if (is_array($resolved)) { Cache::put($key, $resolved, self::REPORT_CACHE_SECONDS); } return $resolved; }); } catch (LockTimeoutException $exception) { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } throw new RuntimeException('inventory_report_generation_busy', 0, $exception); } } /** @param array{start:string,end:string} $period */ private function currentMonthlyInventoryAuthority(array $period): ?array { $month = Carbon::parse($period['start'])->startOfMonth(); $state = $this->monthlyAuthorityStateQuery() ?->whereDate('source_month', '=', $month->toDateString()) ->first(); if ($state === null) { $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) ->whereNotNull('active_generation') ->whereDate('source_month', '=', $month->toDateString()) ->first(); } if ($state === null || trim((string) $state->active_generation) === '') { return null; } return [ 'generation' => (string) $state->active_generation, 'source_month' => $month->toDateString(), ]; } /** @param array{start:string,end:string} $period */ private function previousMonthlyInventoryAuthority(array $period): ?array { $month = Carbon::parse($period['start'])->startOfMonth(); $state = $this->monthlyAuthorityStateQuery() ?->whereDate('source_month', '<', $month) ->orderByDesc('source_month') ->first(); if ($state === null) { $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) ->whereNotNull('active_generation') ->whereDate('source_month', '<', $month) ->orderByDesc('source_month') ->first(); } if ($state === null || trim((string) $state->active_generation) === '') { return null; } return [ 'generation' => (string) $state->active_generation, 'source_month' => Carbon::parse($state->source_month)->startOfMonth()->toDateString(), ]; } private function monthlyAuthorityStateQuery(): ?Builder { try { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable('inventory_report_authoritative_months')) { return null; } return $this->connection->table('inventory_report_authoritative_months') ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) ->where('status', 'succeeded') ->whereNotNull('active_generation'); } catch (Throwable) { return null; } } /** @return array{0:string,1:list} */ private function subwarehouseIdSql(string $providerColumn, string $warehouseColumn): array { $cases = []; $bindings = []; foreach (self::SPECIAL_SUBWAREHOUSE_NAMES as $provider => $names) { foreach ($names as $id => $name) { $cases[] = "WHEN {$providerColumn} = ? AND {$warehouseColumn} = ? THEN {$id}"; array_push($bindings, $provider, $name); } } foreach (self::SUBWAREHOUSE_SUFFIXES as $id => $suffix) { $cases[] = 'WHEN ' . $warehouseColumn . ' = ' . $this->concatSql([$providerColumn, '?']) . " THEN {$id}"; $bindings[] = $suffix; } return ['CASE ' . implode(' ', $cases) . ' ELSE -1 END', $bindings]; } /** @return array{0:string,1:list} */ private function subwarehouseNameSql(string $providerColumn, string $subwarehouseColumn): array { $cases = []; $bindings = []; foreach (self::SPECIAL_SUBWAREHOUSE_NAMES as $provider => $names) { foreach ($names as $id => $name) { $cases[] = "WHEN {$providerColumn} = ? AND {$subwarehouseColumn} = {$id} THEN ?"; array_push($bindings, $provider, $name); } } foreach (self::SUBWAREHOUSE_SUFFIXES as $id => $suffix) { $cases[] = "WHEN {$subwarehouseColumn} = {$id} THEN " . $this->concatSql([$providerColumn, '?']); $bindings[] = $suffix; } $fallback = $this->concatSql([$providerColumn, "'其他子仓('", $subwarehouseColumn, "')'"]); return ['CASE ' . implode(' ', $cases) . ' ELSE ' . $fallback . ' END', $bindings]; } /** @param list $parts */ private function concatSql(array $parts): string { return $this->connection->getDriverName() === 'sqlite' ? '(' . implode(' || ', $parts) . ')' : 'CONCAT(' . implode(', ', $parts) . ')'; } /** * @param array $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 $filters * @return array{suppliers:list,categories:list} */ 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 $filters * @return Generator> */ 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 $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 $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::HEADER_TABLE . ' as h'); // MariaDB otherwise scans every historical header for the all-warehouse roll-forward. if ($warehouseId === null && $this->connection->getDriverName() === 'mysql') { $query->forceIndex('tpw_headers_report_index'); } $query->join(self::ITEM_TABLE . ' as i', function ($join) use ($itemGeneration): void { $join->on('i.io_id', '=', 'h.io_id') ->where('i.generation', '=', $itemGeneration); }) ->where('h.generation', $headerGeneration) ->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 $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, ]; } /** @return array{dimension:string,value:string,start:string,end:string,days:int} */ private function inventoryReportPeriod(string $month): array { try { $start = preg_match('/^\d{4}-\d{2}$/', trim($month)) === 1 ? Carbon::createFromFormat('!Y-m', trim($month))->startOfMonth() : now()->startOfMonth(); } catch (Throwable) { $start = now()->startOfMonth(); } $minimum = Carbon::parse(self::INVENTORY_REPORT_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 $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 */ 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 $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 */ 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 $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) . '%'; } /** @return Collection */ private function boundedGet(Builder $query, ?int $timeoutSeconds = null): Collection { if ($this->connection->getDriverName() !== 'mysql') { return $query->get(); } $timeoutSeconds = max(1, $timeoutSeconds ?? self::REPORT_QUERY_TIMEOUT_SECONDS); return collect($this->connection->select( 'SET STATEMENT max_statement_time=' . $timeoutSeconds . ' FOR ' . $query->toSql(), $query->getBindings(), )); } private function boundedCount(Builder $query, ?int $timeoutSeconds = null): int { $count = $this->connection->query() ->fromSub($query, 'bounded_count') ->selectRaw('COUNT(*) AS aggregate'); return (int) ($this->boundedGet($count, $timeoutSeconds)->first()->aggregate ?? 0); } private function isStatementTimeout(Throwable $exception): bool { $message = strtolower($exception->getMessage()); return str_contains($message, 'max_statement_time') || str_contains($message, 'query execution was interrupted'); } }