connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror') ); } public function ready(): bool { $schema = Schema::connection($this->connection->getName()); return $schema->hasTable('business_mirror_sync_states') && $schema->hasTable('product_price_mirror_rows'); } public function initialized(): bool { $state = $this->state(); return is_array($state) && ($state['initial_completed_at'] ?? null) !== null; } public function maintenanceReady(): bool { $schema = Schema::connection($this->connection->getName()); return $schema->hasTable('product_price_overrides') && $schema->hasTable('product_price_override_events'); } /** @return array|null */ public function state(): ?array { $row = $this->connection->table('business_mirror_sync_states') ->where('dataset_key', self::DATASET_KEY) ->first(); return $row === null ? null : (array) $row; } public function paginate(array $filters, int $page, int $limit, ?int $companyId = null): LengthAwarePaginator { $page = max($page, 1); $limit = min(max($limit, 1), 100); [$query, $withOverrides] = $this->effectiveQuery($companyId ?? (int) (company_id() ?: 1)); $this->applyFilters($query, $filters, $withOverrides); $this->applySelect($query, $withOverrides); return $this->applyOrder($query, $withOverrides) ->paginate($limit, ['*'], 'page', $page); } public function exportRows(array $filters, ?int $companyId = null): iterable { [$query, $withOverrides] = $this->effectiveQuery($companyId ?? (int) (company_id() ?: 1)); $this->applyFilters($query, $filters, $withOverrides); $this->applySelect($query, $withOverrides); return $this->applyOrder($query, $withOverrides)->cursor(); } /** * @param array> $records keyed by upstream key * @return array{created:int,updated:int} */ public function applyOverrides( array $records, int $companyId, ?int $userId, string $source, ): array { if (! $this->maintenanceReady()) { throw new RuntimeException('product_price_maintenance_schema_missing'); } if ($records === []) { return ['created' => 0, 'updated' => 0]; } $keys = array_values(array_unique(array_keys($records))); return $this->connection->transaction(function () use ( $records, $keys, $companyId, $userId, $source, ): array { [$effective, $withOverrides] = $this->effectiveQuery($companyId); $this->applySelect($effective, $withOverrides); $current = $effective->whereIn('r.upstream_key', $keys)->get()->keyBy('upstream_key'); if ($current->count() !== count($keys)) { $missing = collect($keys)->first(fn (string $key): bool => ! $current->has($key)); throw new RuntimeException('product_price_record_not_found:' . $missing); } $existing = $this->connection->table('product_price_overrides') ->where('company_id', $companyId) ->whereIn('upstream_key', $keys) ->pluck('upstream_key') ->flip(); $created = 0; $updated = 0; $now = now(); foreach ($records as $upstreamKey => $changes) { $row = (array) $current->get($upstreamKey); $before = $this->editableValues($row); $provided = array_intersect_key($changes, array_flip(self::EDITABLE_FIELDS)); $after = array_replace($before, $provided); if (trim((string) ($after['barcode'] ?? '')) === '') { throw new RuntimeException('product_price_barcode_required:' . $upstreamKey); } $identity = ['company_id' => $companyId, 'upstream_key' => $upstreamKey]; $values = $after + [ 'source' => $source, 'updated_by' => $userId, 'updated_at' => $now, ]; if ($existing->has($upstreamKey)) { $this->connection->table('product_price_overrides')->where($identity)->update($values); $updated++; } else { $this->connection->table('product_price_overrides')->insert($identity + $values + [ 'created_by' => $userId, 'created_at' => $now, ]); $created++; } $this->connection->table('product_price_override_events')->insert([ 'company_id' => $companyId, 'upstream_key' => $upstreamKey, 'source' => $source, 'before_values' => json_encode($before, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), 'after_values' => json_encode($after, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), 'user_id' => $userId, 'created_at' => $now, ]); } return compact('created', 'updated'); }); } public function startRun(string $mode, Carbon $since, Carbon $until, bool $resume): array { $now = now(); $values = [ 'source_key' => (string) config('upstream_mirrors.product_prices.source_key', 'operations_replica'), 'source_object' => (string) config('upstream_mirrors.product_prices.source_object', 'rpt.Bulk_Price_Limit'), 'status' => 'running', 'run_mode' => $mode, 'last_attempted_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]; if (! $resume) { $values += [ 'window_started_at' => $since, 'window_ends_at' => $until, 'cursor_completed_at' => null, 'cursor_business_id' => null, 'cursor_product_code' => null, 'last_upserted_rows' => 0, 'created_at' => $now, ]; } $query = $this->connection->table('business_mirror_sync_states') ->where('dataset_key', self::DATASET_KEY); if ($query->exists()) { $query->update($values); } else { $this->connection->table('business_mirror_sync_states')->insert( ['dataset_key' => self::DATASET_KEY, 'created_at' => $now] + $values, ); } return $this->state() ?? []; } public function storeBatch(array $rows, array $cursor, int $cumulativeRows): void { $now = now(); $mapped = array_map(function (array $row) use ($now): array { $businessId = (string) ($row['business_id'] ?? ''); $barcode = (string) ($row['barcode'] ?? ''); $cursorCompletedAt = (string) ($row['cursor_completed_at'] ?? $row['completed_at'] ?? ''); $values = [ 'business_id' => $businessId, 'barcode' => $barcode, 'spu' => $row['spu'] ?? null, 'product_name' => $row['product_name'] ?? null, 'category' => $row['category'] ?? null, 'production_status' => $row['production_status'] ?? null, 'minimum_price' => $row['minimum_price'] ?? null, 'price_limit' => $row['price_limit'] ?? null, 'purchase_price' => $row['purchase_price'] ?? null, 'shipping_fee' => $row['shipping_fee'] ?? null, 'jingxi_limit_price' => $row['jingxi_limit_price'] ?? null, 'approval_status' => $row['approval_status'] ?? null, 'approval_result' => $row['approval_result'] ?? null, 'workflow_title' => $row['workflow_title'] ?? null, 'completed_at' => $row['completed_at'], ]; return $values + [ 'upstream_key' => hash('sha256', $cursorCompletedAt . '|' . $businessId . '|' . $barcode), 'row_hash' => hash('sha256', json_encode($values, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION)), 'synced_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; }, $rows); $this->connection->transaction(function () use ($mapped, $cursor, $cumulativeRows, $now): void { if ($mapped !== []) { $this->connection->table('product_price_mirror_rows')->upsert( $mapped, ['upstream_key'], [ 'business_id', 'barcode', 'spu', 'product_name', 'category', 'production_status', 'minimum_price', 'price_limit', 'purchase_price', 'shipping_fee', 'jingxi_limit_price', 'approval_status', 'approval_result', 'workflow_title', 'completed_at', 'row_hash', 'synced_at', 'updated_at', ], ); } $this->connection->table('business_mirror_sync_states') ->where('dataset_key', self::DATASET_KEY) ->update([ 'cursor_completed_at' => $cursor['completed_at'], 'cursor_business_id' => $cursor['business_id'], 'cursor_product_code' => $cursor['barcode'], 'last_upserted_rows' => $cumulativeRows, 'updated_at' => $now, ]); }); } public function complete(string $mode, Carbon $windowEnd, int $rows): void { $now = now(); $values = [ 'status' => 'succeeded', 'last_succeeded_at' => $windowEnd, 'last_upserted_rows' => $rows, 'cursor_completed_at' => null, 'cursor_business_id' => null, 'cursor_product_code' => null, 'last_error_code' => null, 'updated_at' => $now, ]; if ($mode === 'full') { $values['initial_completed_at'] = $now; } $this->connection->table('business_mirror_sync_states') ->where('dataset_key', self::DATASET_KEY) ->update($values); } public function fail(string $errorCode): void { $this->connection->table('business_mirror_sync_states') ->where('dataset_key', self::DATASET_KEY) ->update([ 'status' => 'failed', 'last_error_code' => mb_substr($errorCode, 0, 80), 'updated_at' => now(), ]); } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; } /** @return array{0:Builder,1:bool} */ private function effectiveQuery(int $companyId): array { $query = $this->connection->table('product_price_mirror_rows as r'); $withOverrides = $this->maintenanceReady(); if ($withOverrides) { $query->leftJoin('product_price_overrides as o', function ($join) use ($companyId): void { $join->on('o.upstream_key', '=', 'r.upstream_key') ->where('o.company_id', '=', $companyId); }); } return [$query, $withOverrides]; } private function applyFilters(Builder $query, array $filters, bool $withOverrides): void { if (($filters['barcode'] ?? '') !== '') { $query->whereRaw( $this->effectiveColumn('barcode', $withOverrides) . " LIKE ? ESCAPE '!'", [$this->contains((string) $filters['barcode'])], ); } if (($filters['completed_from'] ?? '') !== '') { $query->where('r.completed_at', '>=', (string) $filters['completed_from'] . ' 00:00:00'); } if (($filters['completed_to'] ?? '') !== '') { $query->where('r.completed_at', '<', Carbon::parse((string) $filters['completed_to'])->addDay()->startOfDay()); } } private function applySelect(Builder $query, bool $withOverrides): void { $query->select([ 'r.id', 'r.upstream_key', 'r.business_id', 'r.approval_status', 'r.approval_result', 'r.workflow_title', 'r.completed_at', ]); foreach (self::EDITABLE_FIELDS as $field) { $query->addSelect(DB::raw($this->effectiveColumn($field, $withOverrides) . ' AS ' . $field)); } if ($withOverrides) { $query->addSelect([ DB::raw('CASE WHEN o.id IS NULL THEN 0 ELSE 1 END AS is_overridden'), 'o.updated_at as override_updated_at', ]); } else { $query->addSelect([ DB::raw('0 AS is_overridden'), DB::raw('NULL AS override_updated_at'), ]); } } private function applyOrder(Builder $query, bool $withOverrides): Builder { return $query ->orderByDesc('r.completed_at') ->orderByDesc('r.business_id') ->orderByRaw($this->effectiveColumn('barcode', $withOverrides) . ' ASC'); } private function effectiveColumn(string $field, bool $withOverrides): string { return $withOverrides ? "CASE WHEN o.id IS NULL THEN r.{$field} ELSE o.{$field} END" : "r.{$field}"; } /** @return array */ private function editableValues(array $row): array { return array_combine( self::EDITABLE_FIELDS, array_map(static fn (string $field): mixed => $row[$field] ?? null, self::EDITABLE_FIELDS), ); } }