*/ 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 */ private const OUTPUT_COLUMNS = [ 'owner', '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', 'turnover_days', 'opening_amount', 'closing_amount', 'price_type', 'order_type', 'other_price_3', 'other_price_4', 'package_count', 'model_spec', 'board_type', 'purchase_type', 'other_attribute_4', 'production_status', 'other_attribute_6', 'item_no', 'single_double_package', 'drawing_id', 'material', ]; 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::PRICE_CHANGE_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(); } /** @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(clone $query); $rows = $query->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{count:int,opening_qty:float,closing_qty:float,opening_amount:float,closing_amount:float} */ public function summary(array $filters): array { $state = $this->requireReadyState(); return $this->summarizeQuery($this->filteredReportQuery($filters, $state)); } /** * @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): Generator { $state = $this->requireReadyState(); $batchSize = min(max($batchSize, 1), 5000); $lastSku = null; while (true) { $query = $this->filteredReportQuery($filters, $state); if ($lastSku !== null) { $query->where('sku_code', '>', $lastSku); } $rows = $query->orderBy('sku_code')->limit($batchSize)->get(); if ($rows->isEmpty()) { return; } foreach ($rows as $row) { yield $this->mapRow($row); } $lastSku = (string) $rows->last()->sku_code; if ($rows->count() < $batchSize) { return; } } } /** @param array $state */ private function filteredReportQuery(array $filters, array $state): Builder { $query = $this->connection->query()->fromSub( $this->reportQuery($filters, $state), '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): 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'); $anchorAt = $this->stateAnchor($state); if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) { throw new RuntimeException('third_party_warehouse_mirror_not_ready'); } $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'); $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]); $signedQuantity = '(COALESCE(movement_qty, 0) * COALESCE(movement_qty_type, 0))'; $closingExpression = 'a.snapshot_qty + COALESCE(SUM(CASE ' . 'WHEN a.snapshot_at < ? AND mv.occurred_at > a.snapshot_at AND mv.occurred_at < ? THEN ' . $signedQuantity . ' ' . 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signedQuantity . ' ' . 'ELSE 0 END), 0)'; $allMovements = $this->confirmedMovements($headerGeneration, $itemGeneration) ->select([ 'i.sku_code', 'i.qty as movement_qty', 'i.qty_type as movement_qty_type', 'h.occurred_at', ]); $balances = $this->connection->query()->fromSub($anchors, 'a') ->leftJoinSub($allMovements, 'mv', 'mv.sku_code', '=', 'a.sku_code') ->groupBy('a.sku_code', 'a.snapshot_qty', 'a.snapshot_at') ->select('a.sku_code') ->selectRaw($closingExpression . ' AS closing_qty', [ $period['end'], $period['end'], $period['end'], $period['end'], ]); $periodMovements = $this->confirmedMovements($headerGeneration, $itemGeneration) ->where('h.occurred_at', '>=', $period['start']) ->where('h.occurred_at', '<', $period['end']) ->groupBy('i.sku_code') ->select('i.sku_code'); 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], ); } $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 = $this->connection->query()->fromSub($balances, 'b') ->leftJoinSub($periodMovements, 'm', 'm.sku_code', '=', 'b.sku_code') ->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'); }); $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 . '))'; $openingPrice = $this->priceAtExpression($period['start'], true); $closingPrice = $this->priceAtExpression($period['end'], false); $owner = (string) config( 'upstream_mirrors.third_party_warehouse_storage.owner_name', '常州简尚家居有限公司', ); $report->selectRaw('? AS owner', [$owner]) ->addSelect([ 'b.sku_code', $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'), ]) ->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') ->selectRaw( 'CASE WHEN COALESCE(m.sales_out, 0) = 0 THEN NULL ' . 'ELSE ROUND((1.0 * b.closing_qty / ABS(m.sales_out)) * ?, 0) END AS turnover_days', [$period['days']], ) ->selectRaw( 'ROUND(' . $openingQuantity . ' * (' . $openingPrice['sql'] . '), 2) AS opening_amount', $openingPrice['bindings'], ) ->selectRaw( 'ROUND(b.closing_qty * (' . $closingPrice['sql'] . '), 2) AS closing_amount', $closingPrice['bindings'], ) ->addSelect([ $this->connection->raw('s.other_price_1 AS price_type'), $this->connection->raw('s.other_price_2 AS order_type'), $this->connection->raw('s.other_price_3 AS other_price_3'), $this->connection->raw('s.other_price_4 AS other_price_4'), $this->connection->raw('s.pack_qty AS package_count'), $this->connection->raw('s.other_1 AS model_spec'), $this->connection->raw('s.other_2 AS board_type'), $this->connection->raw('s.other_3 AS purchase_type'), $this->connection->raw('s.other_4 AS other_attribute_4'), $this->connection->raw('s.other_5 AS production_status'), $this->connection->raw('s.other_6 AS other_attribute_6'), $this->connection->raw('s.other_7 AS item_no'), $this->connection->raw('s.other_8 AS single_double_package'), $this->connection->raw('s.other_9 AS drawing_id'), $this->connection->raw('s.other_10 AS material'), ]); return $report; } private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder { return $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.wms_co_id', self::WAREHOUSE_ID) ->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', '!=', ''); } /** * @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(); } 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(); } 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(); } /** @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, self::PRICE_CHANGE_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; } } /** @return array{sql:string,bindings:list} */ private function priceAtExpression(string $boundary, bool $opening): array { $table = self::PRICE_CHANGE_TABLE; $parts = []; $bindings = []; if ($opening) { $parts[] = "(SELECT exact_price.originalPrice FROM {$table} AS exact_price " . 'WHERE exact_price.skucode = b.sku_code AND exact_price.priceType = ? ' . 'AND exact_price.effectStartTime = ? ORDER BY exact_price.Id DESC LIMIT 1)'; array_push($bindings, 1, $boundary); } $parts[] = "(SELECT prior_price.afterPrice FROM {$table} AS prior_price " . 'WHERE prior_price.skucode = b.sku_code AND prior_price.priceType = ? ' . 'AND prior_price.effectStartTime < ? ' . 'ORDER BY prior_price.effectStartTime DESC, prior_price.Id DESC LIMIT 1)'; array_push($bindings, 1, $boundary); $parts[] = "(SELECT future_price.originalPrice FROM {$table} AS future_price " . 'WHERE future_price.skucode = b.sku_code AND future_price.priceType = ? ' . 'AND future_price.effectStartTime >= ? ' . 'ORDER BY future_price.effectStartTime ASC, future_price.Id ASC LIMIT 1)'; array_push($bindings, 1, $boundary); $parts[] = 's.purchase_price'; return [ 'sql' => 'COALESCE(' . implode(', ', $parts) . ')', 'bindings' => $bindings, ]; } /** @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,opening_amount:float,closing_amount: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, ' . 'COALESCE(SUM(opening_amount), 0) AS opening_amount, ' . 'COALESCE(SUM(closing_amount), 0) AS closing_amount', )->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), 'opening_amount' => round((float) ($row->opening_amount ?? 0), 2), 'closing_amount' => round((float) ($row->closing_amount ?? 0), 2), ]; } /** @return array */ private function mapRow(object $row): array { $mapped = []; foreach (self::OUTPUT_COLUMNS as $column) { $mapped[$column] = $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); } foreach (['opening_amount', 'closing_amount'] as $column) { $mapped[$column] = $mapped[$column] === null ? null : round((float) $mapped[$column], 2); } $mapped['turnover_days'] = $mapped['turnover_days'] === null ? null : (int) round((float) $mapped['turnover_days']); 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)]); } } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; } }