diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml
index 3e1428fd..71ee7f53 100644
--- a/.forgejo/workflows/deploy-tradein.yml
+++ b/.forgejo/workflows/deploy-tradein.yml
@@ -322,14 +322,19 @@ jobs:
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
# .env.runtime already sourced above (set -a; source .env.runtime).
+ #
+ # ⚠️ psql variable substitution (:'pw') НЕ работает внутри -c
+ # (переменная доходит до сервера as literal → syntax error at ':').
+ # Решение: передаём SQL через stdin (< file), psql интерполирует
+ # :'pw' ВНЕ dollar-quoted блока. Детали: ops/db-bootstrap/set_gendesign_reader_password.sql.
if [ -n "${TRADEIN_READER_PASSWORD:-}" ]; then
echo "→ Applying gendesign_reader password from env"
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on \
- -c "ALTER ROLE gendesign_reader PASSWORD :'TRADEIN_READER_PASSWORD'" \
- -v "TRADEIN_READER_PASSWORD=${TRADEIN_READER_PASSWORD}"
+ -v "pw=${TRADEIN_READER_PASSWORD}" \
+ < ops/db-bootstrap/set_gendesign_reader_password.sql
else
- echo "⚠️ TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
+ echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
fi
# Retry backend lifespan hook AFTER migrations applied.
diff --git a/backend/app/services/etl/newbuilding_crossload.py b/backend/app/services/etl/newbuilding_crossload.py
index 833959ed..6f101599 100644
--- a/backend/app/services/etl/newbuilding_crossload.py
+++ b/backend/app/services/etl/newbuilding_crossload.py
@@ -123,8 +123,12 @@ _UPSERT_SQL = text("""
rating = EXCLUDED.rating,
reviews_count = EXCLUDED.reviews_count,
houses_by_turn = EXCLUDED.houses_by_turn,
- cian_internal_house_id = EXCLUDED.cian_internal_house_id,
- yandex_jk_id = EXCLUDED.yandex_jk_id,
+ cian_internal_house_id = COALESCE(
+ EXCLUDED.cian_internal_house_id,
+ newbuilding_listings.cian_internal_house_id),
+ yandex_jk_id = COALESCE(
+ EXCLUDED.yandex_jk_id,
+ newbuilding_listings.yandex_jk_id),
raw_payload = EXCLUDED.raw_payload,
source_last_scraped_at = EXCLUDED.source_last_scraped_at,
crossloaded_at = now()
diff --git a/backend/tests/services/test_newbuilding_crossload.py b/backend/tests/services/test_newbuilding_crossload.py
index 4cb732cd..232cc7f1 100644
--- a/backend/tests/services/test_newbuilding_crossload.py
+++ b/backend/tests/services/test_newbuilding_crossload.py
@@ -143,3 +143,29 @@ def test_upsert_sql_contains_do_update():
"""UPSERT-запрос обновляет строку при конфликте (DO UPDATE SET)."""
sql_text = str(_UPSERT_SQL)
assert "DO UPDATE SET" in sql_text.upper()
+
+
+def test_upsert_sql_coalesce_external_ids():
+ """yandex_jk_id и cian_internal_house_id используют COALESCE в DO UPDATE.
+
+ Источники cian/yandex пишут попеременно — важно не затирать чужой id NULL'ом.
+ Проверяем: в секции DO UPDATE каждая из двух колонок встречается после COALESCE,
+ а не как голое EXCLUDED.
(что затёрло бы чужой id NULL'ом).
+ """
+ sql_text = str(_UPSERT_SQL)
+ sql_upper = sql_text.upper()
+
+ do_update_idx = sql_upper.index("DO UPDATE SET")
+ do_update_section = sql_upper[do_update_idx:]
+
+ # COALESCE должен присутствовать в секции DO UPDATE
+ assert "COALESCE" in do_update_section, "DO UPDATE должен содержать COALESCE"
+
+ # yandex_jk_id — строка вида "YANDEX_JK_ID = COALESCE("
+ assert (
+ "YANDEX_JK_ID = COALESCE(" in do_update_section
+ ), "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
+ # cian_internal_house_id — строка вида "CIAN_INTERNAL_HOUSE_ID = COALESCE("
+ assert (
+ "CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section
+ ), "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
diff --git a/tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql b/tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql
new file mode 100644
index 00000000..cb6575b3
--- /dev/null
+++ b/tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql
@@ -0,0 +1,54 @@
+-- Set gendesign_reader password from env.
+-- Applied by .forgejo/workflows/deploy-tradein.yml after migrations:
+-- psql -v pw="$TRADEIN_READER_PASSWORD" -f ops/db-bootstrap/set_gendesign_reader_password.sql
+-- cwd на деплое = /opt/gendesign/tradein-mvp (путь относительный).
+--
+-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет
+-- (migration 101_gendesign_reader_role.sql может ещё не примениться на первом деплое).
+-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной.
+--
+-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками.
+--
+-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted
+-- блока ($$...$$) — это правило psql, не bug. Поэтому password передаём в DO
+-- через сессионный GUC (set_config), который psql интерполирует ВНЕ dollar
+-- quote, и читаем внутри через current_setting().
+-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на
+-- "syntax error at or near ':'" — :'pw' дошёл до сервера as literal вместо
+-- интерполяции при использовании -c вместо stdin + файл.
+--
+-- Источник переменной pw: TRADEIN_READER_PASSWORD из tradein-mvp/.env.runtime.
+-- Передаётся через: psql -v "pw=${TRADEIN_READER_PASSWORD}"
+--
+-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное
+-- значение. Без `\o /dev/null` psql напечатал бы пароль на stdout → leak в
+-- Forgejo Actions deploy logs (retained, visible всем с repo read access).
+-- Поэтому оборачиваем оба set_config вызова в `\o /dev/null` / `\o` brackets
+-- — output mutes только для этих строк, NOTICE-сообщения из DO block
+-- (idempotency signal) остаются видимыми.
+--
+-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$).
+-- Корректный rollback — unset TRADEIN_READER_PASSWORD в
+-- /opt/gendesign/tradein-mvp/.env.runtime на VPS, deploy-tradein.yml
+-- тогда пропустит этот шаг полностью.
+
+\o /dev/null
+SELECT set_config('app.reader_pw', :'pw', false);
+\o
+
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gendesign_reader') THEN
+ EXECUTE format('ALTER ROLE gendesign_reader WITH PASSWORD %L', current_setting('app.reader_pw'));
+ RAISE NOTICE 'gendesign_reader password set';
+ ELSE
+ RAISE NOTICE 'gendesign_reader role missing — migration 101_gendesign_reader_role.sql not applied yet';
+ END IF;
+END $$;
+
+-- Clear GUC after use (defense-in-depth — не оставляем password в session state
+-- даже на short connection). Same \o trick — set_config return value is empty
+-- string here, но всё равно лишний row в stdout не нужен.
+\o /dev/null
+SELECT set_config('app.reader_pw', '', false);
+\o