connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } public function available(): bool { $schema = Schema::connection($this->connection->getName()); return $schema->hasTable(self::STATE_TABLE) && $schema->hasTable(self::TABLE); } /** @return array|null */ public function state(): ?array { if (! $this->available()) { return null; } $row = $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->first(); return $row === null ? null : (array) $row; } /** @return array{code:string,initialized:bool,last_succeeded_at:?string} */ public function syncStatus(): array { $state = $this->state(); if (! is_array($state)) { return ['code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null]; } if (($state['initial_completed_at'] ?? null) === null) { return [ 'code' => match ((string) ($state['status'] ?? 'pending')) { 'running' => 'initializing', 'failed' => 'initial_failed', default => 'pending', }, 'initialized' => false, 'last_succeeded_at' => null, ]; } return [ 'code' => ($state['status'] ?? null) === 'failed' ? 'failed' : 'ready', 'initialized' => true, 'last_succeeded_at' => ($state['last_succeeded_at'] ?? null) === null ? null : (string) $state['last_succeeded_at'], ]; } /** @return array */ public function startRun( string $mode, string $generation, Carbon $since, Carbon $until, bool $resume, ): array { $now = now(); $values = [ 'status' => 'running', 'run_mode' => $mode, 'generation' => $generation, 'last_attempted_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]; if (! $resume) { $values += [ 'window_started_at' => $since, 'window_ends_at' => $until, 'cursor_window_started_at' => $since, 'cursor_page' => 1, 'last_processed_source_rows' => 0, 'last_upserted_rows' => 0, ]; } $query = $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY); if ($query->exists()) { $query->update($values); } else { $this->connection->table(self::STATE_TABLE)->insert( ['dataset_key' => self::DATASET_KEY, 'created_at' => $now] + $values, ); } return $this->state() ?? []; } /** @param list> $records */ public function storePage( array $records, Carbon $cursorWindowStartedAt, int $cursorPage, string $generation, int $processedSourceRows, int $upsertedRows, ): void { $now = now(); $mapped = array_map(function (array $record) use ($generation, $now): array { $skuId = trim((string) ($record['sku_id'] ?? '')); $values = [ 'operation_id' => $this->operationId($record), 'sku_id' => $skuId, 'sku_id_normalized' => $this->normalizeSku($skuId), 'i_id' => $this->nullableString($record['i_id'] ?? null), 'product_name' => $this->nullableString($record['product_name'] ?? null), 'properties_value' => $this->nullableString($record['properties_value'] ?? null), 'sku_type' => $this->nullableString($record['sku_type'] ?? null), 'operation_type' => $this->nullableString($record['operation_type'] ?? null), 'operation_description' => $this->nullableString($record['operation_description'] ?? null), 'operator_name' => $this->nullableString($record['operator_name'] ?? null), 'operated_at' => $this->dateTime($record['operated_at'] ?? null), 'before_price' => $record['before_price'] ?? null, 'after_price' => $record['after_price'] ?? null, 'diff_amount' => $record['diff_amount'] ?? null, ]; return $values + [ '_mirror_generation' => $generation, 'row_hash' => hash('sha256', json_encode( $values, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR, )), '_mirror_synced_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; }, $records); $this->connection->transaction(function () use ( $mapped, $cursorWindowStartedAt, $cursorPage, $generation, $processedSourceRows, $upsertedRows, $now, ): void { if ($mapped !== []) { $this->connection->table(self::TABLE)->upsert( $mapped, ['operation_id'], [ 'sku_id', 'sku_id_normalized', 'i_id', 'product_name', 'properties_value', 'sku_type', 'operation_type', 'operation_description', 'operator_name', 'operated_at', 'before_price', 'after_price', 'diff_amount', '_mirror_generation', 'row_hash', '_mirror_synced_at', 'updated_at', ], ); } $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update([ 'cursor_window_started_at' => $cursorWindowStartedAt, 'cursor_page' => $cursorPage, 'last_processed_source_rows' => $processedSourceRows, 'last_upserted_rows' => $upsertedRows, 'updated_at' => $now, ]); }); } public function complete( string $mode, string $generation, Carbon $until, int $processedSourceRows, int $upsertedRows, ): void { $now = now(); $this->connection->transaction(function () use ( $mode, $generation, $until, $processedSourceRows, $upsertedRows, $now, ): void { $values = [ 'status' => 'succeeded', 'cursor_window_started_at' => null, 'cursor_page' => null, 'last_succeeded_at' => $until, 'last_processed_source_rows' => $processedSourceRows, 'last_upserted_rows' => $upsertedRows, 'mirror_row_count' => $this->connection->table(self::TABLE)->count(), 'last_error_code' => null, 'updated_at' => $now, ]; if ($mode === 'full') { $values['initial_completed_at'] = $now; $values['last_full_succeeded_at'] = $now; } $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update($values); }); } public function fail(string $errorCode): void { $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update([ 'status' => 'failed', 'last_error_code' => mb_substr($errorCode, 0, 120), 'updated_at' => now(), ]); } /** @param list $normalizedSkus @return Collection> */ public function historyForSkus(array $normalizedSkus): Collection { if ($normalizedSkus === []) { return collect(); } return $this->connection->table(self::TABLE) ->whereIn('sku_id_normalized', $normalizedSkus) ->orderBy('sku_id_normalized') ->orderByDesc('operated_at') ->orderByDesc('operation_id') ->get([ 'operation_id', 'sku_id', 'sku_id_normalized', 'operation_type', 'operation_description', 'operator_name', 'operated_at', 'before_price', 'after_price', 'diff_amount', ]) ->groupBy('sku_id_normalized'); } /** @return list> */ public function formatHistory(Collection $records): array { return $records->map(static fn (object $record): array => [ 'id' => $record->operation_id, 'source' => '聚水潭商品及库存管理', 'record_type_label' => '成本价变更', 'cost_price' => $record->after_price, 'before_price' => $record->before_price, 'after_price' => $record->after_price, 'diff_amount' => $record->diff_amount, 'effect_start_time' => $record->operated_at, 'effect_end_time' => null, 'remark' => $record->operation_description, 'created_at' => $record->operated_at, 'operator' => $record->operator_name, 'operation' => $record->operation_type, ])->values()->all(); } private function normalizeSku(mixed $value): string { return mb_strtoupper(trim((string) $value)); } /** @param array $record */ private function operationId(array $record): string { $operationId = trim((string) ($record['operation_id'] ?? '')); if ($operationId !== '') { return $operationId; } return hash('sha256', json_encode([ $this->normalizeSku($record['sku_id'] ?? null), $record['operation_description'] ?? null, $record['operator_name'] ?? null, $record['operated_at'] ?? null, ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); } private function nullableString(mixed $value): ?string { if ($value === null) { return null; } $value = trim((string) $value); return $value === '' ? null : $value; } private function dateTime(mixed $value): ?string { $value = $this->nullableString($value); if ($value === null) { return null; } try { return Carbon::parse($value)->format('Y-m-d H:i:s.u'); } catch (\Throwable) { return null; } } }