Phase5_VatAsCountryProperty: - countries.VatRate (numeric(5,2)) — ставка страны, источник правды. Seed: KZ=16, RU=20, BY=20, DE=19, CN=13, TR=18, UZ=12, KG=12, KR=10, IT=22, PL=23, US=0. - organizations.ShowVatEnabledOnProduct (bool, default false) — флаг отображения на карточке товара. - organizations.DefaultVat удалён (заменён страной). - products.Vat ОСТАЁТСЯ: для KZ есть льготные категории (хлеб/молоко = 0%) и фискальный чек требует ставку на каждой позиции. Country domain: + DefaultCurrency / VatRate (уже было DefaultCurrencyId из Phase4, сейчас дополнено). Organization domain: DefaultVat убран, ShowVatEnabledOnProduct добавлен. Backend: - ProductInput.Vat теперь int? — если UI скрывает поле и прислал null, ProductsController берёт дефолт из страны организации (Country.VatRate при создании; при update сохраняет прежнее значение). - CountriesController.List/Get/Create/Update возвращает/принимает DefaultCurrency и VatRate. - OtherSystem импорт: дефолт Vat загружается из страны организации. - SystemReferenceSeeder: новые валюты BYN/UZS/KGS/TRY/KRW/PLN, seed country-currency-vat для всех 12 стран. - OrganizationSettingsController: VatRate read-only из страны, ShowVatEnabledOnProduct редактируется. Web: - Country type + CountriesPage форма редактирования (валюта, ставка НДС). - OrganizationSettingsPage: "Ставка НДС" read-only (берётся из страны, ссылка на /catalog/countries), галочка "Указывать ставку НДС на товаре". - ProductEditPage: блок Ставка НДС % + галка "В том числе НДС" теперь показываются только если showVatEnabledOnProduct=true. В payload при save.mutate отправляется vat=null если скрыто. - ProductsPage: колонка НДС показывается только при включённом флаге. Galleries/products/settings других этапов — не задеты.
199 lines
12 KiB
C#
199 lines
12 KiB
C#
using foodmarket.Domain.Catalog;
|
||
using foodmarket.Infrastructure.Persistence;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace foodmarket.Api.Seed;
|
||
|
||
// Populates a starter catalog so the system is usable immediately after first run.
|
||
// Runs only in Development and only if the tenant has no products yet — safe to run multiple times.
|
||
public class DemoCatalogSeeder : IHostedService
|
||
{
|
||
private readonly IServiceProvider _services;
|
||
private readonly IHostEnvironment _env;
|
||
|
||
public DemoCatalogSeeder(IServiceProvider services, IHostEnvironment env)
|
||
{
|
||
_services = services;
|
||
_env = env;
|
||
}
|
||
|
||
public async Task StartAsync(CancellationToken ct)
|
||
{
|
||
if (!_env.IsDevelopment()) return;
|
||
|
||
using var scope = _services.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||
|
||
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
|
||
if (demoOrg is null) return;
|
||
var orgId = demoOrg.Id;
|
||
|
||
// Skip if products already present — don't re-seed on every restart.
|
||
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||
if (hasProducts) return;
|
||
|
||
// KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%.
|
||
const int vatDefault = 16;
|
||
const int vat0 = 0;
|
||
|
||
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
|
||
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
|
||
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
|
||
|
||
if (unitSht is null) return;
|
||
|
||
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
|
||
if (retailPriceType is null) return;
|
||
|
||
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
|
||
if (kzt is null) return;
|
||
|
||
var kz = await db.Countries.FirstOrDefaultAsync(c => c.Code == "KZ", ct);
|
||
var ru = await db.Countries.FirstOrDefaultAsync(c => c.Code == "RU", ct);
|
||
|
||
// Product groups (hierarchy — Напитки/Безалкогольные, Напитки/Алкогольные, etc.)
|
||
var groups = new Dictionary<string, Guid>();
|
||
Guid AddGroup(string name, Guid? parentId)
|
||
{
|
||
var id = Guid.NewGuid();
|
||
var path = parentId is null
|
||
? name
|
||
: $"{groups.First(g => g.Value == parentId).Key}/{name}";
|
||
db.ProductGroups.Add(new ProductGroup
|
||
{
|
||
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
|
||
Path = path, SortOrder = groups.Count, IsActive = true,
|
||
});
|
||
groups[path] = id;
|
||
return id;
|
||
}
|
||
|
||
var gDrinks = AddGroup("Напитки", null);
|
||
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
|
||
AddGroup("Алкогольные", gDrinks);
|
||
var gDairy = AddGroup("Молочные продукты", null);
|
||
var gBakery = AddGroup("Хлеб и выпечка", null);
|
||
var gSweets = AddGroup("Кондитерские", null);
|
||
var gGrocery = AddGroup("Бакалея", null);
|
||
var gSnacks = AddGroup("Снеки", null);
|
||
|
||
// Demo suppliers
|
||
var supplier1 = new Counterparty
|
||
{
|
||
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
|
||
Type = CounterpartyType.LegalEntity,
|
||
Bin = "100140005678", CountryId = kz?.Id,
|
||
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
||
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
||
IsActive = true,
|
||
};
|
||
var supplier2 = new Counterparty
|
||
{
|
||
OrganizationId = orgId, Name = "ИП Иванов А.С.",
|
||
Type = CounterpartyType.Individual,
|
||
Iin = "850101300000", CountryId = kz?.Id,
|
||
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
||
IsActive = true,
|
||
};
|
||
db.Counterparties.AddRange(supplier1, supplier2);
|
||
|
||
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
|
||
// When user does real приёмка, real barcodes will overwrite.
|
||
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit)[]
|
||
{
|
||
// Напитки — безалкогольные
|
||
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id),
|
||
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id),
|
||
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
|
||
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id),
|
||
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id),
|
||
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id),
|
||
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id),
|
||
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id),
|
||
// Молочные
|
||
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id),
|
||
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id),
|
||
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id),
|
||
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
|
||
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id),
|
||
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id),
|
||
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id),
|
||
// Хлеб и выпечка
|
||
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id),
|
||
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
|
||
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
|
||
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id),
|
||
// Кондитерские
|
||
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id),
|
||
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
|
||
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
|
||
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
|
||
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id),
|
||
// Бакалея
|
||
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id),
|
||
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
|
||
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
|
||
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id),
|
||
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id),
|
||
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id),
|
||
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id),
|
||
// Снеки
|
||
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id),
|
||
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id),
|
||
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
|
||
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
|
||
};
|
||
|
||
var products = demo.Select(d =>
|
||
{
|
||
var p = new Product
|
||
{
|
||
OrganizationId = orgId,
|
||
Name = d.Name,
|
||
Article = d.Article,
|
||
UnitOfMeasureId = d.Unit,
|
||
// Хлеб/батон/лепёшка — 0% (льготная категория в KZ).
|
||
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
||
? vat0 : vatDefault,
|
||
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
|
||
ProductGroupId = d.Group,
|
||
CountryOfOriginId = d.Country,
|
||
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
|
||
IsActive = true,
|
||
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
||
PurchaseCurrencyId = kzt.Id,
|
||
Prices =
|
||
[
|
||
new ProductPrice
|
||
{
|
||
OrganizationId = orgId,
|
||
PriceTypeId = retailPriceType.Id,
|
||
Amount = d.RetailPrice,
|
||
CurrencyId = kzt.Id,
|
||
},
|
||
],
|
||
Barcodes =
|
||
[
|
||
new ProductBarcode
|
||
{
|
||
OrganizationId = orgId,
|
||
Code = d.Barcode,
|
||
Type = BarcodeType.Ean13,
|
||
IsPrimary = true,
|
||
},
|
||
],
|
||
};
|
||
return p;
|
||
}).ToList();
|
||
|
||
db.Products.AddRange(products);
|
||
await db.SaveChangesAsync(ct);
|
||
}
|
||
|
||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||
}
|