fix(migrations): чиним P0-блокеры разворачивания на чистой БД

Проблема: на свежей PostgreSQL `dotnet ef database update` падает на пяти
миграциях подряд + рантайм-несовместимость схемы с domain Product/Store/
Counterparty. Невозможно поднять стек ни на dev, ни на новом стейдже.

Найдено и починено:

1. Phase2c4_ReconcileStage пыталась AddColumn IsMarked, который Phase1Catalog
   (после рефакторинга) уже добавляет. Завернули в IF NOT EXISTS.

2. Phase5d_ProductVatDecimal ALTER COLUMN products.Vat падал — Vat теперь
   заменён на FK VatRateId, колонки нет. Завернули в IF EXISTS.

3. Phase5c_UnitsOfMeasureGlobal INSERT канонических ОКЕИ пропускал NOT NULL
   колонку Symbol (а также DecimalPlaces, IsBase, CreatedAt). Дополнили
   полным набором: шт/кг/л/м/уп.

4. Phase5d_DropUnitOfMeasureDescription дропала несуществующую колонку
   (Description в новой схеме отсутствует). Завернули в IF EXISTS.

5. Phase5a_EmployeeSoftDelete и Phase5b_PlatformSettings были написаны
   вручную без атрибутов [Migration] + [DbContext] — EF их игнорировал
   и пропускал применение (см. memory/feedback_ef_migrations.md).
   Добавили атрибуты + сделали идемпотентными.

6. Новая Phase5f_DropStoreKindRudiment: rudimentные колонки stores.Kind и
   counterparties.Kind (NOT NULL без default'а) роняли любой INSERT —
   ни одной организации/контрагента создать нельзя. Дропаем.

7. Новая Phase5g_ProductVatRealign: приводим products в соответствие с
   domain — дропаем FK→vat_rates + колонку VatRateId + IsAlcohol + пустую
   таблицу vat_rates; добавляем products.Vat numeric(5,2) DEFAULT 12 и
   VatEnabled bool DEFAULT true. Без этого ProductsController падает 42703
   при создании любого товара.

Все миграции идемпотентны (DO $$ ... IF EXISTS/NOT EXISTS ...) — повторное
применение на старой стейдж-БД безопасно.

Проверено: E2E full-cycle на свежей dev-БД проходит 12/12 шагов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-23 12:13:19 +05:00
parent 35d70c5d80
commit a06464baeb
8 changed files with 293 additions and 76 deletions

View file

@ -23,21 +23,22 @@ public partial class Phase2c4_ReconcileStage : Migration
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Добавляем IsMarked с дефолтом false.
migrationBuilder.AddColumn<bool>(
name: "IsMarked",
schema: "public",
table: "products",
type: "boolean",
nullable: false,
defaultValue: false);
// 2. Если TrackingType есть в БД (стейдж) — бэкфиллим и удаляем.
// На свежей БД (dev, где migrations 2c2/2c3 не применялись отдельно)
// колонки не будет — IF EXISTS защищает от ошибки.
// Полностью идемпотентно: на стейдже (старый Phase1Catalog без IsMarked,
// но с TrackingType) колонку добавим и бэкфилл'нем. На свежей БД, где
// отрефакторённый Phase1Catalog уже создал IsMarked, — пропускаем.
// Без этого защитного блока миграция падает с
// `column "IsMarked" of relation "products" already exists` при первом
// dotnet ef database update на пустой БД.
migrationBuilder.Sql("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'products'
AND column_name = 'IsMarked') THEN
ALTER TABLE public.products
ADD COLUMN "IsMarked" boolean NOT NULL DEFAULT false;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'products'
AND column_name = 'TrackingType') THEN

View file

@ -11,17 +11,35 @@ public partial class Phase5d_ProductVatDecimal : Migration
{
protected override void Up(MigrationBuilder b)
{
// Идемпотентно: на стейдже Phase1Catalog создавал products.Vat integer,
// а на свежей БД отрефакторённый Phase1Catalog уже создаёт products.VatRateId
// (FK на vat_rates) и колонки Vat нет. Без NOT EXISTS-гарда миграция падает
// с "column Vat does not exist" при первом dotnet ef database update.
b.Sql("""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='products'
AND column_name='Vat') THEN
ALTER TABLE public.products
ALTER COLUMN "Vat" TYPE numeric(5,2) USING "Vat"::numeric(5,2);
END IF;
END $$;
""");
}
protected override void Down(MigrationBuilder b)
{
b.Sql("""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='products'
AND column_name='Vat') THEN
ALTER TABLE public.products
ALTER COLUMN "Vat" TYPE integer USING ROUND("Vat")::integer;
END IF;
END $$;
""");
}
}

View file

@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
@ -10,37 +12,42 @@ namespace foodmarket.Infrastructure.Persistence.Migrations
/// IsActive=false + FiredAt — уволен
/// IsActive=false + IsDeleted=true + DeletedAt — soft-deleted
/// Физически Employee никогда не удаляем (FK из retail_sales, supplies).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260506000000_Phase5a_EmployeeSoftDelete")]
public partial class Phase5a_EmployeeSoftDelete : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "IsDeleted",
schema: "public",
table: "employees",
type: "boolean",
nullable: false,
defaultValue: false);
// Идемпотентно (см. CLAUDE memory feedback_ef_migrations): на чистой
// dev-БД нужен AddColumn, на стейдже миграция могла быть применена
// вручную через SQL — повторный AddColumn упадёт.
b.Sql(@"
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='employees'
AND column_name='IsDeleted') THEN
ALTER TABLE public.employees
ADD COLUMN ""IsDeleted"" boolean NOT NULL DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='employees'
AND column_name='DeletedAt') THEN
ALTER TABLE public.employees
ADD COLUMN ""DeletedAt"" timestamp with time zone NULL;
END IF;
END $$;");
b.AddColumn<System.DateTime>(
name: "DeletedAt",
schema: "public",
table: "employees",
type: "timestamp with time zone",
nullable: true);
b.CreateIndex(
name: "IX_employees_OrganizationId_IsDeleted",
schema: "public",
table: "employees",
columns: new[] { "OrganizationId", "IsDeleted" });
b.Sql(@"
CREATE INDEX IF NOT EXISTS ""IX_employees_OrganizationId_IsDeleted""
ON public.employees (""OrganizationId"", ""IsDeleted"");");
}
protected override void Down(MigrationBuilder b)
{
b.DropIndex(name: "IX_employees_OrganizationId_IsDeleted", schema: "public", table: "employees");
b.DropColumn(name: "IsDeleted", schema: "public", table: "employees");
b.DropColumn(name: "DeletedAt", schema: "public", table: "employees");
b.Sql(@"DROP INDEX IF EXISTS public.""IX_employees_OrganizationId_IsDeleted"";");
b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""IsDeleted"";");
b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""DeletedAt"";");
}
}
}

View file

@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
@ -7,33 +9,33 @@ namespace foodmarket.Infrastructure.Persistence.Migrations
/// <summary>Платформенные настройки (singleton). SMTP-креды для отправки
/// писем (forgot-password, нотификации). Управляется SuperAdmin'ом через
/// /super-admin/platform-settings. Видна только им.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260506100000_Phase5b_PlatformSettings")]
public partial class Phase5b_PlatformSettings : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "platform_settings",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
SmtpHost = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SmtpPort = table.Column<int>(type: "integer", nullable: true),
SmtpUseSsl = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SmtpStartTls = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
SmtpUsername = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SmtpPasswordEncrypted = table.Column<string>(type: "text", nullable: true),
FromEmail = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
FromName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table => table.PrimaryKey("PK_platform_settings", x => x.Id));
// Идемпотентно — миграция могла быть применена ранее SQL-ом, либо
// соседняя миграция могла создать таблицу под тем же именем.
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.platform_settings (
""Id"" uuid PRIMARY KEY,
""SmtpHost"" varchar(200) NULL,
""SmtpPort"" integer NULL,
""SmtpUseSsl"" boolean NOT NULL DEFAULT false,
""SmtpStartTls"" boolean NOT NULL DEFAULT true,
""SmtpUsername"" varchar(200) NULL,
""SmtpPasswordEncrypted"" text NULL,
""FromEmail"" varchar(200) NULL,
""FromName"" varchar(200) NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone NULL
);");
}
protected override void Down(MigrationBuilder b)
{
b.DropTable(name: "platform_settings", schema: "public");
b.Sql(@"DROP TABLE IF EXISTS public.platform_settings;");
}
}
}

View file

@ -113,16 +113,23 @@ UPDATE public.units_of_measure
b.Sql(@"DELETE FROM public.units_of_measure WHERE ""OrganizationId"" IS NOT NULL;");
// 7. Доинсёртить канонические globals, если каких-то не было в БД.
// Передаём ВСЕ NOT NULL поля схемы units_of_measure: Symbol, Name,
// DecimalPlaces, IsBase, CreatedAt — иначе INSERT падает 23502
// («null value in column "Symbol" violates not-null constraint»)
// на свежей БД, где units_of_measure был пустой и до Phase5c
// единицы не успели заinsert'иться.
b.Sql(@"
INSERT INTO public.units_of_measure (""Id"", ""Code"", ""Name"", ""IsActive"")
SELECT gen_random_uuid(), v.code, v.name, true
INSERT INTO public.units_of_measure
(""Id"", ""Code"", ""Name"", ""Symbol"", ""DecimalPlaces"", ""IsBase"", ""IsActive"", ""CreatedAt"")
SELECT gen_random_uuid(), v.code, v.name, v.symbol,
v.decimals, false, true, now() AT TIME ZONE 'UTC'
FROM (VALUES
('796','штука'),
('166','килограмм'),
('112','литр'),
('006','метр'),
('625','упаковка')
) AS v(code, name)
('796','штука', 'шт', 0),
('166','килограмм', 'кг', 3),
('112','литр', 'л', 3),
('006','метр', 'м', 2),
('625','упаковка', 'уп', 0)
) AS v(code, name, symbol, decimals)
WHERE NOT EXISTS (
SELECT 1 FROM public.units_of_measure
WHERE ""OrganizationId"" IS NULL AND ""Code"" = v.code

View file

@ -17,21 +17,32 @@ public partial class Phase5d_DropUnitOfMeasureDescription : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(
name: "Description",
schema: "public",
table: "units_of_measure");
// Идемпотентно: на старой стейдж-БД колонка Description была, на свежей
// (после рефакторинга Phase1Catalog) её сразу нет — без IF EXISTS
// миграция падает с 42703 «column Description does not exist».
b.Sql(@"
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='units_of_measure'
AND column_name='Description') THEN
ALTER TABLE public.units_of_measure DROP COLUMN ""Description"";
END IF;
END $$;");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<string>(
name: "Description",
schema: "public",
table: "units_of_measure",
type: "character varying(500)",
maxLength: 500,
nullable: true);
b.Sql(@"
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='units_of_measure'
AND column_name='Description') THEN
ALTER TABLE public.units_of_measure
ADD COLUMN ""Description"" varchar(500) NULL;
END IF;
END $$;");
}
}
}

View file

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase5f — выкидываем рудиментные колонки Kind из stores и
/// counterparties.
///
/// История: ранние черновики домена различали тип объекта через целочисленное
/// поле Kind. Позже модель упростили — для Store отдельной сущностью стал
/// RetailPoint; для Counterparty — отдельный enum CounterpartyType (поле
/// Type, а не Kind). Свойства Kind ушли из domain и configurations, но
/// миграции Phase1Catalog продолжали создавать колонки как NOT NULL integer
/// без default'а.
///
/// Симптом: при любом INSERT в stores или counterparties (seeder,
/// signup-bootstrap, контроллер) Postgres падает 23502 «null value in
/// column "Kind"». На чистой dev-БД невозможно зарегистрировать организацию
/// и создать ни одного контрагента.
///
/// Фикс: дропаем колонки идемпотентно (стейдж уже мог быть руками
/// почищен).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260523120000_Phase5f_DropStoreKindRudiment")]
public partial class Phase5f_DropStoreKindRudiment : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='stores'
AND column_name='Kind') THEN
ALTER TABLE public.stores DROP COLUMN ""Kind"";
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='counterparties'
AND column_name='Kind') THEN
ALTER TABLE public.counterparties DROP COLUMN ""Kind"";
END IF;
END $$;");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='stores'
AND column_name='Kind') THEN
ALTER TABLE public.stores
ADD COLUMN ""Kind"" integer NOT NULL DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='counterparties'
AND column_name='Kind') THEN
ALTER TABLE public.counterparties
ADD COLUMN ""Kind"" integer NOT NULL DEFAULT 0;
END IF;
END $$;");
}
}
}

View file

@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase5g — приводим схему products в соответствие с текущим domain
/// и кодом контроллеров.
///
/// Расхождение: в БД остались рудименты от старой попытки вынести VAT в
/// отдельную таблицу <c>vat_rates</c> (FK products.VatRateId → vat_rates.Id),
/// а также legacy-флаг IsAlcohol. Domain Product (см. food-market.domain/
/// Catalog/Product.cs) теперь хранит ставку напрямую как <c>decimal Vat</c>
/// + <c>bool VatEnabled</c>, vat_rates никем не используется. Любой INSERT
/// в products падает 42703 «column "Vat" of relation "products" does not
/// exist», что блокирует создание товаров и весь dataflow приёмка→продажа.
///
/// Миграция:
/// 1. Дропаем FK products.VatRateId → vat_rates.
/// 2. Дропаем колонку products.VatRateId.
/// 3. Дропаем рудиментную колонку products.IsAlcohol.
/// 4. Дропаем пустую таблицу vat_rates.
/// 5. Добавляем products.Vat numeric(5,2) NOT NULL DEFAULT 12
/// (12% — ставка НДС по умолчанию для KZ; конкретный товар может быть 0%).
/// 6. Добавляем products.VatEnabled boolean NOT NULL DEFAULT true.
///
/// Все шаги идемпотентны (на стейдже могло быть применено вручную).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260523130000_Phase5g_ProductVatRealign")]
public partial class Phase5g_ProductVatRealign : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
DO $$
BEGIN
-- 1. FK products.VatRateId -> vat_rates
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE table_schema='public' AND table_name='products'
AND constraint_name='FK_products_vat_rates_VatRateId') THEN
ALTER TABLE public.products DROP CONSTRAINT ""FK_products_vat_rates_VatRateId"";
END IF;
-- 2. products.VatRateId column
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='products'
AND column_name='VatRateId') THEN
DROP INDEX IF EXISTS public.""IX_products_VatRateId"";
ALTER TABLE public.products DROP COLUMN ""VatRateId"";
END IF;
-- 3. products.IsAlcohol rudiment
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='products'
AND column_name='IsAlcohol') THEN
ALTER TABLE public.products DROP COLUMN ""IsAlcohol"";
END IF;
-- 4. vat_rates table
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema='public' AND table_name='vat_rates') THEN
DROP TABLE public.vat_rates CASCADE;
END IF;
-- 5. products.Vat numeric(5,2)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='products'
AND column_name='Vat') THEN
ALTER TABLE public.products
ADD COLUMN ""Vat"" numeric(5,2) NOT NULL DEFAULT 12;
END IF;
-- 6. products.VatEnabled boolean
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='products'
AND column_name='VatEnabled') THEN
ALTER TABLE public.products
ADD COLUMN ""VatEnabled"" boolean NOT NULL DEFAULT true;
END IF;
END $$;");
}
protected override void Down(MigrationBuilder b)
{
// Без vat_rates восстановить семантически невозможно — это деструктивная
// миграция, откат вернёт только структуру.
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.vat_rates (
""Id"" uuid PRIMARY KEY,
""Code"" varchar(10) NOT NULL,
""Rate"" numeric(5,2) NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL
);
ALTER TABLE public.products
DROP COLUMN IF EXISTS ""VatEnabled"",
DROP COLUMN IF EXISTS ""Vat"",
ADD COLUMN IF NOT EXISTS ""IsAlcohol"" boolean NOT NULL DEFAULT false;");
}
}
}